Valkyrian Labs logo

Protocol

Supported MCP versions, methods, error codes and the stdio transport rules.

Protocol

forti-mcp speaks JSON-RPC 2.0 over stdio, one message per line. It serves both MCP protocol eras in one process (MCP 2026-07-28, "Versioning and Compatibility"), so current and older clients both work.

VersionEraHow a client uses it
2026-07-28Modern, statelessEvery request carries _meta io.modelcontextprotocol/protocolVersion and io.modelcontextprotocol/clientCapabilities
2025-11-25Legacyinitialize starts a session for the rest of the process
2025-06-18Legacyinitialize starts a session for the rest of the process

Modern: 2026-07-28

A request is modern when its params._meta contains io.modelcontextprotocol/protocolVersion. That value must be 2026-07-28, io.modelcontextprotocol/clientCapabilities must be an object, and io.modelcontextprotocol/clientInfo, if present, must be an object.

MethodResult
server/discoversupportedVersions, capabilities (tools), instructions, ttlMs: 300000, cacheScope: "private"
tools/listtools, ttlMs: 300000, cacheScope: "private"
tools/callThe tool result (Tools)

Every modern result also carries resultType: "complete" and _meta io.modelcontextprotocol/serverInfo (name: forti-mcp, title, version, description).

1{"jsonrpc": "2.0", "id": 1, "method": "tools/list",2 "params": {"_meta": {"io.modelcontextprotocol/protocolVersion": "2026-07-28",3                      "io.modelcontextprotocol/clientCapabilities": {}}}}

Legacy: 2025-11-25 and 2025-06-18

initialize requires protocolVersion (a string), capabilities and clientInfo (objects). The server answers with the requested version when it is 2025-11-25 or 2025-06-18, and with 2025-11-25 for anything else, which the client may accept or reject. Its result has protocolVersion, capabilities (tools), serverInfo and instructions (serverInfo.description is left out for 2025-06-18, which predates it). A second initialize is an error.

MethodNotes
initializeStarts the session; once per process
pingAnswers {}; allowed before initialize
tools/listAfter initialize
tools/callAfter initialize

Older versions, such as 2025-03-26, get 2025-11-25 offered back: 2025-03-26 requires JSON-RPC batches, which this server rejects. A legacy session and modern requests can share one process; each request is judged by its own _meta.

Both eras

  • tools/list returns every tool in one page. No cursor is ever issued, so any cursor is invalid.
  • Notifications are never answered, even malformed ones. notifications/initialized needs no action, and notifications/cancelled is ignored: requests are handled one at a time, in order, so a request has already been answered by the time its cancellation is read.
  • Responses from the client (messages with result or error and no method) are ignored; the server never sends requests.
  • The instructions text tells the model the workflow, how each mode answers forti_apply, the byte budget, and that device data is untrusted.

Errors

Protocol errors are for messages the server cannot process: malformed JSON-RPC, unknown methods and unknown tools.

Tool errors are not protocol errors

Everything the model can fix, including invalid tool arguments and every device error, is a tool result with isError: true instead (Tools).

CodeWhen
-32700Parse error: the line is not valid JSON
-32600Invalid Request: a batch (array), not an object, jsonrpc not "2.0", missing or non-string method, an id that is not a string or integer, a line over 4 MiB, nesting deeper than 64 levels, a second initialize
-32601Method not found
-32602Invalid params: params or _meta not an object, a missing or malformed field, a legacy method before initialize (or server/discover without modern _meta), any cursor, an unknown tool
-32603Internal error (details go to stderr, not to the client)
-32022Unsupported protocol version: data.supported lists the versions, data.requested echoes the request

When the id cannot be read, the error response omits it (never null). The -32602 answer to a legacy method without a session, and to an initialize without a version, also lists the supported versions in data.supported.

With --read-only

Calling forti_plan or forti_apply on a server started with --read-only is -32602 "Unknown tool: forti_plan (forti-mcp runs with --read-only; plan and apply are disabled)".

stdio

  • One message per line on stdin, one response per line on stdout. Blank lines are ignored; a last message without a trailing newline is still handled when stdin closes.
  • At most 4 MiB per message. A longer line is answered with -32600 and discarded as it arrives, without being buffered.
  • stdout carries protocol messages only. At startup the server keeps a private descriptor for them and points file descriptor 1 at stderr, so nothing else in the process (or a credential helper it runs) can corrupt the stream.
  • stderr gets rare diagnostics, never request content or secrets.
  • Lifetime: the server runs until stdin closes, then exits with status 0. It exits with 1 when it cannot read stdin or write stdout, and with 2 on invalid command-line options.
  • No batches: MCP removed JSON-RPC batches in 2025-06-18; an array is rejected with -32600.

Try it by hand

The server works without a client. This lists the tools over a legacy session, without contacting a FortiGate:

1printf '%s\n' \2  '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"shell","version":"0"}}}' \3  '{"jsonrpc":"2.0","method":"notifications/initialized"}' \4  '{"jsonrpc":"2.0","id":2,"method":"tools/list"}' \5  | forti-mcp

The first response line is the initialize result:

1{"id":1,"jsonrpc":"2.0","result":{"capabilities":{"tools":{}},"instructions":"forti-mcp manages FortiGate firewalls through the FortiOS REST API only (never SSH or CLI commands). ...","protocolVersion":"2025-11-25","serverInfo":{"description":"FortiGate configuration and monitoring over the FortiOS REST API","name":"forti-mcp","title":"FortiOS (forti-mcp)","version":"0.1.0"}}}