Skip to content

Repository files navigation

mcp-vet

npm version CI node license: MIT

On July 28, 2026 the Model Context Protocol ships its 2026-07-28 specification as final — and it removes several things that today's MCP servers rely on. mcp-vet is a zero-config CLI that scans your MCP server source (TypeScript, JavaScript, and Python) for the exact patterns that will break client interop on that date, and tells you what to change. Since 0.11.0 it also vets Agent Plugins 1.0 packages, the plugin format that went GA in VS Code and GitHub Copilot on 2026-08-12 and ships MCP servers via mcp.json. On top of the 22 protocol rules it carries two advisory SDK-migration groups: PY_SDK_V1_* for the Python SDK v1→v2 port and TS_SDK_V1_* for the TypeScript one.

URL note. The dated permalink 404'd on release day (0.9.0 cited /specification/draft/); it resolves as of 2026-08-01 and every rule docUrl now cites it — a /draft/ URL silently drifts at the next revision, and a test asserts no rule cites one.

npx @booyaka/mcp-vet .

mcp-vet scanning a server — BREAKING and DEPRECATED findings with before/after fixes and confidence tags

No account, no API key — the scan parses your code locally (ts-morph for TS/JS, a bundled Python ast script for .py), makes no network calls, and exits non-zero if it finds anything BREAKING, so you can drop it straight into CI. (The opt-in mcp-vet probe is the one command that talks to a server — and only the one you point it at.)

What actually happens on July 28

July 28 is a specification release date, not a switch that remotely disables your deployment. Nothing reaches into running servers and turns them off. Breakage appears when a client and server pair negotiates or requires the new revision — a client that sends 2026-07-28-style requests (per-request _meta, no handshake, routing headers) against a server that still expects 2025-11-25 semantics, or vice versa.

Two practical consequences:

  • Your rollout is a window, not a day. Until every client you care about has moved, keep both revisions in your production test matrix: a 2025-11-25 path and a 2026-07-28 path. mcp-vet fixtures emits wire-level test fixtures for exactly this (see Runtime conformance fixtures).
  • Silent acceptance is the worst failure mode. A server that quietly processes an old-revision request under new semantics (or the reverse) corrupts behavior instead of failing loudly. Verify refusal behavior, not just the happy path.

The scan tells you what to change in your source; the date tells you when clients start expecting it.


Real-world example

Pointed at the official MCP TypeScript SDK's own example servers, mcp-vet finds the patterns that the 2026-07-28 spec breaks:

legacy-routing.ts:36:29  BREAKING   MCP_SESSION_ID [high]
    const sid = req.headers['mcp-session-id'] as string | undefined;
legacy-routing.ts:41:13  BREAKING   MCP_SESSION_ID [medium]
    sessionIdGenerator: () => randomUUID(),
legacy-routing.ts:70:26  BREAKING   MCP_SESSION_ID [high]
    exposedHeaders: ['Mcp-Session-Id', 'WWW-Authenticate', ...]
sse-polling.ts:34:29     DEPRECATED LOGGING_CAP    [high]
    capabilities: { logging: {} }
sse-polling.ts:102:29    BREAKING   MCP_SESSION_ID [high]
    const sid = req.headers['mcp-session-id'] as string | undefined;
sse-polling.ts:107:13    BREAKING   MCP_SESSION_ID [medium]
    sessionIdGenerator: () => randomUUID(),

6 finding(s): 5 BREAKING, 1 DEPRECATED

Note it catches the sessionIdGenerator session usage — the real signal in SDK-based servers, which usually never write the literal Mcp-Session-Id string. And it stays quiet where it should: the Mcp-Session-Id mentioned in a comment, the initialize in a comment in dual-era.ts, and the sampling/createMessage in sampling.ts (which appears only in comments and behind the requestSampling() helper) are all left alone. That precision — structural AST checks, not text matching — is what keeps the noise down on a real codebase: 6 findings, 0 false positives on these files. (Across the full labeled corpus it's 256/258 true positives — see BENCHMARK.md.)


What it detects

🔴 BREAKING (fails the build — exit code 1)

ID Pattern
MCP_SESSION_ID Mcp-Session-Id header / mcpSessionId variable / client-side session ownership (sessionId passed to or read from a client transport)
INITIALIZE_HANDLER initialize / notifications/initialized handler registration
ERROR_CODE_32002 the numeric error code -32002
ERROR_CODE_RENUMBERED -32001 / -32003 / -32004 in a JSON-RPC error code position → -32020 / -32021 / -32022
TASKS_LEGACY tasks/get · tasks/update · tasks/cancel legacy method strings
TASKS_LIST_REMOVED tasks/list — removed entirely (no replacement listing method)
TASKS_RESULT_REMOVED tasks/result — removed; poll with tasks/get instead (SEP-2663)
PING_REMOVED ping in MCP method-registration context · PingRequestSchema · Python types.PingRequest
RESOURCE_SUBSCRIBE_REMOVED resources/subscribe · resources/unsubscribe · SubscribeRequestSchema · UnsubscribeRequestSchema → subscriptions/listen
ROOTS_LIST_CHANGED_REMOVED notifications/roots/list_changed · RootsListChangedNotificationSchema
LOGGING_SETLEVEL_REMOVED logging/setLevel · SetLevelRequestSchema
SSE_RESUMABILITY_REMOVED Last-Event-ID / lastEventId · eventStore · resumptionToken / onresumptiontoken on a Streamable HTTP transport
ELICITATION_COMPLETE_REMOVED notifications/elicitation/complete · elicitationId

The two reclassified rules matter most if you scanned with ≤ 0.8.0. logging/setLevel and notifications/roots/list_changed used to report as DEPRECATED warnings (exit 0) under LOGGING_CAP / ROOTS_CAP. The final changelog removes them — "Remove ping, logging/setLevel, and notifications/roots/list_changed" — so they now fail the build, while the logging / roots capability keys stay DEPRECATED. A test locks that split so it can't regress.

🟡 DEPRECATED (warns only — exit code 0)

Removal windows come from the deprecated-features registry, quoted verbatim in each finding — not a hardcoded grace period.

ID Pattern Earliest removal (registry)
ROOTS_CAP roots capability first revision released on or after 2027-07-28
SAMPLING_CAP sampling capability first revision released on or after 2027-07-28
LOGGING_CAP logging capability first revision released on or after 2027-07-28
INCLUDE_CONTEXT_VALUES includeContext set to "thisServer" / "allServers" follows Sampling
OAUTH_DCR RFC7591 dynamic client registration (registration_endpoint, …) → Client ID Metadata Documents first revision released on or after 2027-07-28
SSE_TRANSPORT_DEPRECATED the HTTP+SSE transport (SEP-2596): SSEServerTransport / SSEClientTransport / SseServerTransport and the SDK sse module paths (ungated); sse_client / sse_app / connect_sse / handle_post_message and a literal transport: 'sse' (MCP-context-gated); the hand-rolled two-endpoint shape (text/event-stream plus an event: endpoint write — text/event-stream alone never fires) → Streamable HTTP three months after SEP-2596 reaches Final (quoted verbatim from the registry — the SEP is Final, but the registry still states the relative clause, so mcp-vet computes nothing)

Three more report at this exit-0 tier without being deprecations: the final changelog's authorization-hardening MUSTs (Minor changes 7/8/9). They are correctness requirements on code that still works, so they warn instead of failing the build — and all three are gated on file-level MCP context (like SSE_RESUMABILITY_REMOVED), so a plain OAuth client in an unrelated file stays clean (locked by negatives/plain-oauth-client.ts / .py):

ID Fires when (in an MCP-context file) Source
AUTH_ISS_UNVALIDATED an authorization-code redemption (grant_type 'authorization_code') with no iss/issuer read or comparison anywhere in the file — "MCP clients MUST validate a present iss against the recorded issuer before redeeming the authorization code" SEP-2468 / RFC 9207
AUTH_DCR_NO_APPLICATION_TYPE a hand-rolled registration body (redirect_uris + client_name) with no application_type — "Require MCP clients to specify an appropriate application_type during Dynamic Client Registration"; the fix also points at Client ID Metadata Documents (DCR is Deprecated, PR #2858). Bodies routed through an SDK that supplies the parameter (python-sdk's OAuthClientMetadata default, typescript-sdk's deriveApplicationType) are already correct and stay clean SEP-837
AUTH_CREDENTIALS_NOT_ISSUER_KEYED persisted client_id/client_secret stored under a bare constant key or a server/resource-URL variable — "clients MUST key persisted credentials by the issuer identifier"; an issuer-derived key is the migrated form SEP-2352

🐍 Python SDK v1 vs v2 (PY_SDK_V1_*, added in 0.12.0)

MCP Python SDK v2.0.0 went stable 2026-07-28 (v2.1.1 shipped 2026-08-25) and renamed or removed most of the v1 API surface. These 12 rules fire on v1 SDK vocabulary in a project whose declared mcp dependency resolves to major 2, where that vocabulary is either a hard import-time crash (v2.1.1 ships mcp/server/fastmcp.py as a stub that raises ModuleNotFoundError) or a silent behavior change. They are SDK-level, not protocol-level, and all warn at exit 0. Every message quotes the migration guide or a release body.

ID Fires on (in a file that imports mcp)
PY_SDK_V1_FASTMCP from mcp.server.fastmcp import FastMCP → from mcp.server.mcpserver import MCPServer. A hard ModuleNotFoundError under v2
PY_SDK_V1_MCPERROR McpError → MCPError
PY_SDK_V1_CAMEL_FIELDS attribute/kwarg access to inputSchema / outputSchema / isError / nextCursor → snake_case. Raw JSON dicts stay clean, because the wire format is still camelCase in v2
PY_SDK_V1_STREAMABLEHTTP_CLIENT streamablehttp_client → streamable_http_client
PY_SDK_V1_WEBSOCKET mcp.client.websocket / websocket_client. The WebSocket transport, and the ws extra, are removed entirely
PY_SDK_V1_GET_CONTEXT .get_context() → a ctx: Context handler parameter, since context is injected now
PY_SDK_V1_TIMEDELTA a timeout kwarg passed timedelta(...) → float seconds. Client request timeouts now raise -32001 REQUEST_TIMEOUT instead of 408
PY_SDK_V1_ENV MCP_* environment variables next to environ/getenv. v2 never reads them, and the guide notes they never took effect in v1 either
PY_SDK_V1_OAUTH RFC7523OAuthClientProvider / JWTParameters (removed), scopes= on client-credentials providers → scope=, and OAuthClientProvider(timeout=) (removed)
PY_SDK_V1_CACHE_FALSE Client(cache=False) → Client(cache=None)
PY_SDK_V1_FILERESOURCE FileResource(is_binary=...) → encoding: str | None. Passing is_binary= now raises ValidationError
PY_SDK_V1_HTTPX import httpx in a project whose mcp resolves to v2. v2 depends on httpx2>=2.5.0 instead, so declare httpx yourself or port the import. A declared direct httpx dependency stays clean

Gating (--py-sdk auto, the default). The declared mcp specifier is read from the nearest uv.lock / poetry.lock (exact version, wins), pyproject.toml (PEP 621 dependencies, optional-dependency extras, PEP 735 groups, and poetry tables), or requirements*.txt, walking up from each Python file and stopping at the repository boundary, so an unrelated parent manifest can never decide the gate:

  • resolves to v2: the group is active;
  • resolves to v1: the group is suppressed and one informational line names v2.1.1 (2026-08-25) as available (preview with --py-sdk v2);
  • unresolvable (no manifest, no mcp entry, or a range like >=1.26 that admits both majors): active, with every finding annotated "(mcp version undetermined)".

--py-sdk v1|v2 forces a side; --no-py-sdk removes the group entirely and reproduces pre-0.12.0 output byte for byte. The group is additionally gated per file on an actual mcp import, so a local class named FastMCP in a non-MCP file stays clean, and it never gates the 22 protocol rules. A fully v2-ported server importing mcp.server.sse, still a real module in v2.1.1, keeps its SSE findings, which is exactly the under-report 0.12.0 fixes.

🟦 TypeScript SDK v1 vs v2 (TS_SDK_V1_*, added in 0.14.0)

The monolithic @modelcontextprotocol/sdk was retired on 2026-07-27, when @modelcontextprotocol/client, @modelcontextprotocol/server, @modelcontextprotocol/core and the framework adapters (/node, /express, /hono, /fastify) all went stable at 2.0.0. These 17 rules fire on v1 SDK vocabulary in a project whose declared dependencies resolve to that split. Like the Python group they are SDK-level, not protocol-level, and all warn at exit 0. Pinning back to @modelcontextprotocol/sdk@^1 remains valid, the guide describes v1/v2 coexistence during a staged migration, and it sets no end-of-support date for v1.x, so neither does any message here. Every message quotes docs/migration/upgrade-to-v2.md.

ID Fires on (in a file that imports @modelcontextprotocol/*)
TS_SDK_V1_MONOLITH any @modelcontextprotocol/sdk/... import, with the destination named per path (types.js → @modelcontextprotocol/core, server/express → @modelcontextprotocol/express, …). Suppressed on the SSE paths SSE_TRANSPORT_DEPRECATED already owns
TS_SDK_V1_MCPERROR McpError → ProtocolError; ErrorCode → ProtocolErrorCode; ErrorCode.RequestTimeout / .ConnectionClosed → SdkErrorCode
TS_SDK_V1_HTTP_ERROR StreamableHTTPError → SdkHttpError
TS_SDK_V1_JSONRPC_ERROR JSONRPCError → JSONRPCErrorResponse, plus JSONRPCErrorSchema and isJSONRPCError
TS_SDK_V1_JSONRPC_RESPONSE JSONRPCResponse / JSONRPCResponseSchema / isJSONRPCResponse. A silent widening, not a rename: v1 validated only result responses, v2 reuses the name for `result
TS_SDK_V1_HANDLER_EXTRA RequestHandlerExtra and the extra.* reads: extra.signal → ctx.mcpReq.signal, extra.requestId → ctx.mcpReq.id, extra.sendRequest → ctx.mcpReq.send, extra.requestInfo → ctx.http?.req, and the rest of the table. ctx.http is undefined on stdio
TS_SDK_V1_SCHEMA_HANDLER setRequestHandler(CallToolRequestSchema, …) → setRequestHandler('tools/call', …); custom methods take the 3-argument form
TS_SDK_V1_VARIADIC_REG server.tool( / .prompt( / .resource( → registerTool / registerPrompt / registerResource
TS_SDK_V1_WEBSOCKET WebSocketClientTransport, or the sdk/client/websocket module. Removed, because WebSocket is not a spec transport
TS_SDK_V1_NODE_HTTP_TRANSPORT StreamableHTTPServerTransport → NodeStreamableHTTPServerTransport from @modelcontextprotocol/node (or WebStandardStreamableHTTPServerTransport on Workers/Deno/Bun)
TS_SDK_V1_ZOD_COMPAT server/zod-compat.js, server/zod-json-schema-compat.js, and the removed helpers. Only schemaToJson (→ fromJsonSchema()) and parseSchemaAsync (→ z.safeParseAsync()) have a route forward; the guide says getSchemaShape, getSchemaDescription, isOptionalSchema and unwrapOptionalSchema have none
TS_SDK_V1_AUTH_MOVED @modelcontextprotocol/sdk/server/auth/** → @modelcontextprotocol/server-legacy/auth (frozen v1 copy), @modelcontextprotocol/express, or @modelcontextprotocol/server
TS_SDK_V1_RESOURCE_REF ResourceReference / ResourceReferenceSchema → ResourceTemplateReference / …Schema; the ResourceTemplate type from types.js → ResourceTemplateType
TS_SDK_V1_COMPLETABLE_NESTING completable(schema.optional(), cb) → completable(schema, cb).optional(). v2 resolves completion metadata after unwrapping the optional, so the v1 nesting returns empty completion lists and nothing errors
TS_SDK_V1_FINISH_AUTH finishAuth(code) with a bare code string. v2 validates iss from the callback, so pass the callback URL's URLSearchParams instead. Advisory: the guide calls the two one-argument forms statically indistinguishable, so this needs a string literal or a plainly code-named binding
TS_SDK_V1_ISOMORPHIC_HEADERS IsomorphicHeaders → the Web Standard Headers type
TS_SDK_V1_ZOD3 a zod import in a project whose declared zod range admits below ^4.2.0. v1's peer was ^3.25 || ^4.0, which "installs and typechecks cleanly under v2 and only fails at runtime"

Gating (--ts-sdk auto, the default). The declared family is read from the nearest package.json (any dependency block) plus package-lock.json / pnpm-lock.yaml / yarn.lock, walking up from each .ts/.js file and stopping at the repository boundary, so an unrelated parent manifest can never decide the gate:

  • declares any of client / server / core: the group is active;
  • declares @modelcontextprotocol/sdk ^1 and nothing else: the group is suppressed and one informational line names the v2 packages and their 2026-07-27 npm date (preview with --ts-sdk v2);
  • declares both: a staged migration. The group runs, and a note points out that objects must not flow between v1-imported and v2-imported code;
  • unresolvable (no manifest, no MCP entry, or a range like * that names no major): active, with every finding annotated "(SDK version undetermined)".

--ts-sdk v1|v2 forces a side; --no-ts-sdk removes the group entirely and reproduces pre-0.14.0 output byte for byte. Like the Python group it is additionally gated per file on an actual @modelcontextprotocol/* import, so a local class named McpError stays clean, and it never gates the 22 protocol rules. Aliased (import { McpError as Boom }), namespace (import * as sdk), require() and export … from forms all resolve.

Worked example

A one-line server.ts in a project whose package.json declares @modelcontextprotocol/server: "^2.0.0":

import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js';

export function badParams(): never {
  throw new McpError(ErrorCode.InvalidParams, 'unknown resource');
}
$ mcp-vet server.ts

server.ts
server.ts:4:10  DEPRECATED  TS_SDK_V1_MCPERROR [high]
    The migration guide renames the error surface: McpError → ProtocolError; ErrorCode → ProtocolErrorCode. …
    — before:
      4: import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js';
    + after:
      import { ProtocolError, ProtocolErrorCode, SdkErrorCode } from '@modelcontextprotocol/core';

      throw new ProtocolError(ProtocolErrorCode.InvalidParams, "unknown resource");
server.ts:4:37  DEPRECATED  TS_SDK_V1_MONOLITH [high]
    The migration guide splits the single `@modelcontextprotocol/sdk` package into … types.js schemas moved to @modelcontextprotocol/core.
    — before:
      4: import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js';
    + after:
      // v2 (Node 20+): npx @modelcontextprotocol/codemod@latest v1-to-v2 .
      import { MCPServer } from '@modelcontextprotocol/server';
      import { CallToolResultSchema } from '@modelcontextprotocol/core';
      import { StdioServerTransport } from '@modelcontextprotocol/server/stdio';

2 finding(s): 0 BREAKING, 2 DEPRECATED
22 spec rules, 0 breaking; 13 TypeScript SDK rules, 2 advisory

$ echo $?
0

One import line, two things to fix, and the build still passes. That is what the advisory tier means. The whole port is npx @modelcontextprotocol/codemod@latest v1-to-v2 .; these rules tell you whether you still need to run it, and what the codemod left behind.

Confidence

Every finding carries a confidence so you can tune signal-to-noise with --min-confidence:

  • high — exact/deterministic match (session id, -32002, tasks methods), a structurally-verified capability (the roots/sampling/logging key is really inside a capabilities object), or an initialize string used as a method name (handler registration, switch case, or req.method === 'initialize').
  • medium — a roots/sampling/logging key/string within 5 lines of a capabilities mention but not structurally verified; a real sessionIdGenerator; client-side session ownership (sessionId/session_id passed to or read from a transport/client).
  • low — a bare 'initialize' string with no registration context.

Before / after for each BREAKING pattern

1. Mcp-Session-Id — sessions are removed

"The Mcp-Session-Id header and the protocol-level session that came with it are also removed."

// ❌ before
const sessionId = req.headers['Mcp-Session-Id'];
res.setHeader('Mcp-Session-Id', sessionId);

// ✅ after — no session header; client info & capabilities arrive in per-request _meta
function handle(req) {
  const meta = req.params?._meta ?? {};
  // route on meta, not on a session id
}

This cuts both ways — client-side session ownership breaks too, even against a server that scans clean. A lot of tool-reliability bugs only show up when the server is stateless but the client still behaves as if it owns a session:

// ❌ before — the client resumes a stored session
const transport = new StreamableHTTPClientTransport(url, { sessionId: stored });
persist(transport.sessionId);

// ✅ after — stateless: no stored session id, full _meta on every request
const transport = new StreamableHTTPClientTransport(url, { sessionId: undefined });

mcp-vet flags a client transport constructed with a real sessionId/session_id and reads of transport.sessionId (medium confidence). The migrated sessionId: undefined / session_id=None forms are recognized and left alone.

2. initialize / notifications/initialized — the handshake is removed

"The initialize/initialized handshake is removed. The protocol version, client info, and client capabilities that used to be exchanged once at connection time now travel in _meta on every request."

// ❌ before
server.setRequestHandler('initialize', async (req) => ({ protocolVersion, capabilities }));
server.setNotificationHandler('notifications/initialized', () => {});

// ✅ after — read the handshake data from _meta on every request
function handle(req) {
  const { protocolVersion, clientInfo, capabilities } = req.params?._meta ?? {};
}

3. Error code -32002 → -32602

"The error code for a missing resource changes from the MCP-custom -32002 to the JSON-RPC standard -32602 Invalid Params."

// ❌ before
return { error: { code: -32002, message: 'Resource not found' } };

// ✅ after
return { error: { code: -32602, message: 'Invalid params' } };

This one is purely mechanical, so mcp-vet --fix rewrites it for you in place.

4. Legacy Tasks methods — redesigned to a handle-based lifecycle

"A server can answer tools/call with a task handle, and the client drives it with tasks/get, tasks/update, and tasks/cancel. Anyone who shipped against the 2025-11-25 experimental Tasks API will need to migrate to the new lifecycle."

// ❌ before — legacy experimental argument shapes
switch (method) {
  case 'tasks/get':    return getTask(id);
  case 'tasks/update': return updateTask(id);
  case 'tasks/cancel': return cancelTask(id);
}

// ✅ after — tools/call returns a task handle; the same method names now carry
// the NEW argument shapes. mcp-vet flags every use for manual review against
// the 2026-07-28 schema.

5. ping, logging/setLevel, notifications/roots/list_changed — removed

"Remove ping, logging/setLevel, and notifications/roots/list_changed. Log level is now set per-request via io.modelcontextprotocol/logLevel in _meta; servers MUST NOT emit notifications/message for requests that did not include this field."

// ❌ before
server.setRequestHandler(PingRequestSchema, async () => ({}));
server.setRequestHandler(SetLevelRequestSchema, async (r) => setLevel(r.params.level));
server.notification({ method: 'notifications/roots/list_changed' });

// ✅ after — ping is gone (liveness is transport-level); read the level per request
function handle(req) {
  const level = req.params?._meta?.['io.modelcontextprotocol/logLevel'];
  // ...and emit notifications/message ONLY when that field was present
}

A /ping health-check route, a bare 'ping' string, or a tool merely named ping is not flagged — the rule requires MCP method-registration context.

6. resources/subscribe / resources/unsubscribe → subscriptions/listen

"Replace the HTTP GET endpoint and resources/subscribe/resources/unsubscribe with subscriptions/listen: a single long-lived POST-response stream for opted-in server-to-client change notifications."

// ❌ before
server.setRequestHandler(SubscribeRequestSchema, async ({ params }) => subscribe(params.uri));

// ✅ after — the client opts into specific types; the server tags notifications
{
  method: 'subscriptions/listen',
  params: { subscriptions: { toolsListChanged: true, resourcesListChanged: true } },
}
// every notification on that stream carries
// _meta['io.modelcontextprotocol/subscriptionId']

7. SSE resumability — removed

"Remove SSE stream resumability and message redelivery (the Last-Event-ID header and SSE event IDs) from the Streamable HTTP transport. A broken response stream loses the in-flight request; clients MUST re-issue it as a new request with a new request ID."

// ❌ before
const transport = new StreamableHTTPServerTransport({ eventStore });
const lastEventId = req.headers['last-event-id'];

// ✅ after — no event store, no resumption token; retry as a NEW request id
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });

A non-MCP SSE client that legitimately uses Last-Event-ID stays clean — the rule is gated on MCP context (locked by test/fixtures/negatives/sse-client.ts).

8. Error codes -32001 / -32003 / -32004 → -32020 / -32021 / -32022

"-32000 to -32019 remains implementation-defined (existing SDK usage is grandfathered), -32020 to -32099 is reserved for the MCP specification. Renumber the error codes introduced in this draft accordingly — HeaderMismatch -32001 → -32020, MissingRequiredClientCapability -32003 → -32021, UnsupportedProtocolVersion -32004 → -32022."

// ❌ before
return { error: { code: -32004, message: 'Unsupported protocol version' } };

// ✅ after
return { error: { code: -32022, message: 'Unsupported protocol version' } };

Because -32000..-32019 is grandfathered, this only fires in a JSON-RPC error code position (a code: key, an *Error(...) construction, or a comparison against code) — an implementation-defined -32001 constant elsewhere is left alone. Mechanical, so --fix rewrites all three alongside -32002.

9. tasks/list — removed entirely

"The tasks/list method is removed — it was unsafe once protocol-level sessions were gone. There is no replacement listing method."

// ❌ before
case 'tasks/list': return listTasks();

// ✅ after — there is nothing to enumerate server-side. A client tracks the
// task handles it got back from its own tools/call responses.

Needs manual review (not statically detectable)

mcp-vet catches every 2026-07-28 change that has a concrete code-level signal (a header, a method string, an error code, a capability key). A few changes are real but can't be found reliably by static analysis — they're architectural or depend on runtime wiring. A clean scan is not a promise that these are handled, so check them by hand:

  • The long-lived server→client SSE push channel is removed — a server may only send requests to the client while it is actively processing a client request. Standing push streams / out-of-band notifications need rework.
  • Streamable HTTP now requires Mcp-Method and Mcp-Name headers that mirror the JSON-RPC body; servers must reject requests where headers and body disagree.
  • Tool schemas may now be full JSON Schema 2020-12 (oneOf/anyOf/$ref/conditionals); do not auto-dereference external $ref URIs. The dialect half of this — schemas still declaring or using draft-07 forms — is detectable at runtime: mcp-vet probe checks it against your live server.

(Until 0.9.0, auth hardening was on this list. It no longer is: the three authorization MUSTs are covered by the AUTH_* static rules above and the dcr-still-advertised / auth-metadata-missing-iss probe checks. What remains uncovered is the helper-indirection recall boundary — see Known limitations.)

The CLI prints a one-line reminder of these after every scan.

Runtime conformance fixtures

Static analysis proves known legacy patterns are absent from your source. Only wire-level tests prove your running server actually speaks the 2026-07-28 contract. mcp-vet ships both halves:

npx @booyaka/mcp-vet fixtures ./mcp-fixtures

writes eleven ready-to-fire JSON fixtures plus a CHECKLIST.md, covering the runtime behaviors a linter cannot see:

  1. server/discover replaces the initialize handshake
  2. per-request _meta (protocolVersion, clientInfo, capabilities) — including explicit refusal when _meta is missing
  3. Mcp-Method / Mcp-Name routing headers, including the header/body-mismatch rejection case
  4. stateless auth context (no session-bound token cache)
  5. task-handle lifecycle: creation, tasks/get polling, resume on another instance, tasks/list and tasks/result returning method-not-found
  6. duplicate request delivery (idempotency under retries)
  7. retry against a different server instance (no sticky in-memory state)
  8. tools/list cache invalidation
  9. downgrade/refusal: old-revision requests get an explicit error, never silent acceptance under the wrong semantics
  10. subscriptions/listen opt-in: the client opts into specific types, the server acknowledges and tags notifications with io.modelcontextprotocol/subscriptionId, and resources/subscribe now answers -32601
  11. MRTR: the server returns resultType: "input_required" with inputRequests, and the client retries the original request carrying inputResponses

Each fixture is a plain JSON description (send headers + JSON-RPC body, expect notes) you can replay with curl, supertest, pytest + httpx, or any HTTP harness. The checklist also spells out the dual-version rollout matrix — run both 2025-11-25 and 2026-07-28 paths until your clients have all moved — and a client-side assumptions list (session resume, per-request _meta, retries landing on other instances, tools/list revalidation).

Vet a running server (mcp-vet probe)

Where the scan reads your source, probe talks to your running server over the wire — stdio (a command it spawns) or Streamable HTTP (a URL) — and checks the 2026-07-28 violations that only exist at runtime (run is an alias: mcp-vet run … ≡ mcp-vet probe …):

ID Severity What it checks
json-schema-dialect 🟡 WARN calls tools/list and inspects every tool's inputSchema/outputSchema for a pre-2020-12 JSON Schema dialect (SEP-2106) — an explicit draft-04/-06/-07 $schema (high confidence), or no $schema but draft-only keyword forms: definitions instead of $defs, $ref: "#/definitions/…", boolean exclusiveMinimum/exclusiveMaximum, array-form items (medium confidence)
requires-initialize-handshake 🔴 ERROR with --spec-version 2026-07-28: makes a stateless first request — no initialize, protocolVersion/clientInfo/clientCapabilities in namespaced _meta keys per the RC — and flags a server that rejects it or hangs waiting for the removed handshake. A valid tools array in the answer is asserted, not just a 200
missing-server-discover 🔴 ERROR with --spec-version 2026-07-28: calls the server/discover RPC that every 2026-07-28 server MUST implement (SEP-2575 — it replaces the handshake for up-front capability discovery) and flags a server whose answer is an error or lacks the required capabilities key. (The spec defines server/discover as JSON-RPC only — 2026-07-28 removes the HTTP GET endpoint, so there is no GET /mcp/discover to fall back to)
legacy-resource-error-code 🔴 ERROR with --spec-version 2026-07-28: reads a deliberately nonexistent resource URI and flags a server that still answers with the MCP-custom -32002 instead of the JSON-RPC standard -32602 (Invalid Params). Servers without resources/read (-32601) are skipped, not flagged
# vet the schemas of a stdio server (spawns the command; a lone .js file runs with Node)
npx @booyaka/mcp-vet probe node ./dist/server.js

# full 2026-07-28 readiness: stateless first contact + server/discover +
# resource error code + schema dialects
npx @booyaka/mcp-vet probe --spec-version 2026-07-28 http://localhost:3000/mcp
mcp-vet probe — node ./dist/server.js · spec 2026-07-28 · stdio · 12 tool(s) listed
  stateless probe: stateless tools/list was rejected: -32002 Server not initialized
  fallback probe: initialize handshake + tools/list succeeded
  server/discover: rejected (-32601)
  resource error-code check skipped — server does not implement resources/read (-32601)

ERROR  requires-initialize-handshake [high]
    The server rejected (or hung on) a stateless 2026-07-28-style first request ...
ERROR  missing-server-discover [high]
    The 2026-07-28 spec requires every server to implement the server/discover RPC ...
WARN   json-schema-dialect [high]
    tool "echo" inputSchema: $schema = http://json-schema.org/draft-07/schema# (draft-07)

The stateless verdict is cross-checked before it becomes a violation: requires-initialize-handshake is only emitted when the classic 2025-11-25 handshake path does work — a dead or non-MCP server is an operational error (exit 2), never a false violation. The server/discover and error-code checks then run on whichever contact path succeeded, so even a handshake-only server gets its complete migration report in one probe. The dialect walker recurses only into schema positions (applicators like properties/allOf), so a property literally named definitions is never mistaken for the draft-07 keyword, and an explicit 2020-12 $schema declaration is trusted.

--spec-version — which revision to vet against

Value Behavior
2025-11-25 (default) today's stable contract: classic initialize handshake, then the json-schema-dialect check. No 2026-07-28 assertions run — a fully 2025-era server probes clean
2026-07-28 the full new-spec compliance suite: stateless first contact, required server/discover, -32602 resource error code, plus the dialect check

Migration note. The default stays 2025-11-25 so existing CI invocations keep their exact behavior — add the flag when you are ready, not when the spec ships. A practical rollout:

  1. Today: mcp-vet probe <server> (unchanged) plus the static scan in CI.
  2. When you start migrating: add a second CI job with --spec-version 2026-07-28 --fail-on none to see the new-spec violations without failing the build.
  3. When your server targets 2026-07-28 (e.g. after moving to @modelcontextprotocol/server 2.x): drop --fail-on none so the three ERROR-level checks gate the build. A correctly migrated server passes all of them; the pre-migration server fails requires-initialize-handshake and missing-server-discover immediately.
  4. Keep a 2025-11-25 probe in the matrix until every client you serve has moved (the rollout is a window, not a day — see What actually happens on July 28).

--spec 2026-07-28 — the extra compliance suite

--spec is a shorthand for --spec-version that also runs thirteen additional wire-level checks on top of the ones above. --spec 2026-07-28 vets against the new revision and adds the suite; plain --spec-version 2026-07-28 is unchanged and never runs it, so existing CI invocations keep their exact behavior.

# full readiness AND the extra compliance suite
npx @booyaka/mcp-vet probe --spec 2026-07-28 node ./dist/server.js
ID Severity What it checks
stateless-no-session 🔴 ERROR sends tools/list with no Mcp-Session-Id and flags a server that rejects it with a session error — sessions are removed on 2026-07-28 (SEP-2567), so a stateless request must be served
stateless-no-init 🔴 ERROR sends tools/list with no initialize/initialized handshake and flags a server that rejects it as uninitialized — the handshake is removed (SEP-2575); a compliant server answers the first request directly
required-headers 🔴 ERROR sends a request carrying the now-required Mcp-Method / Mcp-Name routing headers and flags a server that errors on them. Skipped for stdio targets (there are no request headers over stdio)
deprecated-sampling 🟡 WARN observes a server-initiated sampling/createMessage request. Sampling is deprecated in 2026-07-28 and eligible for removal July 2027 — migrate to a direct LLM provider API
deprecated-roots 🟡 WARN flags a roots/list that returns a result — the roots capability is deprecated
deprecated-logging 🟡 WARN observes a server-emitted notifications/message — the MCP logging protocol is deprecated; migrate to stderr (stdio) or OpenTelemetry
missing-result-type 🔴 ERROR every result must carry resultType — "complete" or "input_required" (SEP-2322). Inspects tools/list plus prompts/list, resources/list, resources/templates/list; endpoints the server doesn't implement are skipped
missing-cacheable-fields 🟡 WARN the cacheable list results must carry ttlMs and a cacheScope of "public" or "private" (SEP-2549)
legacy-error-code-renumbered 🔴 ERROR sends an unsupported protocolVersion and flags a server still answering -32001 / -32003 / -32004 instead of -32020 / -32021 / -32022
ping-still-answered 🟡 WARN sends a ping and flags a server that returns a result instead of -32601 — the method is removed
dcr-still-advertised 🟡 WARN fetches the authorization-server metadata (RFC 9728 protected-resource lookup, then RFC 8414, falling back to the MCP origin) and flags one that still advertises registration_endpoint with no client_id_metadata_document_supported alternative — DCR is Deprecated in favour of Client ID Metadata Documents (PR #2858)
auth-metadata-missing-iss 🟡 WARN flags authorization-server metadata that omits authorization_response_iss_parameter_supported — clients cannot rely on the RFC 9207 iss mix-up protection SEP-2468 requires them to validate
legacy-sse-transport 🟡 WARN issues a fresh GET on the endpoint with Accept: text/event-stream after the standard probe completes, and flags a server whose answer is a 2xx text/event-stream stream that actually delivers an event: endpoint frame — the legacy two-endpoint HTTP+SSE transport (Deprecated, SEP-2596; the GET endpoint itself is removed by SEP-2575). A 405/404/non-SSE/JSON answer is a clean note; an SSE stream that never names an endpoint before --timeout is inconclusive, never a violation. Skipped for stdio targets

Every one of these is cross-checked the same way the rest of the probe is — an inconclusive outcome is reported as a note, never as a violation, and a dead or non-MCP server is an operational error (exit 2). The two auth-metadata checks specifically: stdio targets skip them (well-known metadata is an HTTP concern), and a server that advertises no OAuth metadata at all is an inconclusive note — many MCP servers use no OAuth, and that is not a violation. The two stateless-* checks specifically: a server that answers a stateless, session-less, handshake-less tools/list passes both; one that rejects it is classified by why — a session error trips stateless-no-session, an uninitialized error trips stateless-no-init (a session rejection trips both, since a sessionful server is also not answering the first request directly). The two deprecated-sampling / deprecated-logging checks watch for server→client traffic for a short window (up to the spec's 5 s, bounded by --timeout) and report only what the server actually sends — a server that never samples or logs stays clean. The suite runs on its own fresh connection after the standard probe completes, so the ERROR checks above are unaffected.

Probe findings use the same report formats as the scan: --json (machine-readable array on stdout) and --sarif [file] (SARIF 2.1.0 — ERROR maps to error, WARN to warning), plus --fail-on breaking|any|none (default breaking: exit 1 only on ERROR), --timeout <ms> (default 8000, also the hang-detection window), --quiet, and --color/--no-color.

Try it against the official reference server — the July 2026 @modelcontextprotocol/server-everything (beta 2026-07-28 SDK) answers stateless requests and already returns the new -32602 resource error code, but it does not implement server/discover yet and its tool schemas still declare draft-07 — probe reports exactly that (1 ERROR, 13 WARN):

npx @booyaka/mcp-vet probe --spec-version 2026-07-28 npx -y @modelcontextprotocol/server-everything stdio

Vet an Agent Plugins 1.0 package (mcp-vet plugin)

Agent Plugins 1.0 went GA on 2026-08-12 in VS Code, Copilot CLI, the GitHub Copilot SDK, and the Copilot app, installed by default from the Awesome Copilot marketplace. A plugin is a directory with a plugin.json manifest, an optional skills/ folder, and an optional mcp.json declaring MCP servers, which makes mcp.json a first-class distribution channel for MCP servers.

The plugin format is one protocol revision behind the protocol it packages. The 1.0.0 schema still accepts type: "sse", which the MCP 2026-07-28 spec reclassifies as Deprecated (SEP-2596) and whose stream resumability it removes.

Schema-valid and spec-conformant are not the same thing here. The published plugin.schema.json rejects two manifest conditions the spec requires clients to accept: agent-plugins-spec#77. §5.2 says clients "MUST report and ignore each unknown field and MUST continue loading the plugin", and §8.1 says a non-object extensions means the client "MUST report and ignore the field and continue loading components" — but the schema closes the root (additionalProperties: false) and types extensions as an object, so a validate-and-reject tool calls both fatal. Since 0.13.0, plugin.json findings are therefore severity-split by what a conformant client actually does:

  • FATAL — a conformant client rejects the plugin (no manifest, unparsable JSON, missing/wrong-typed required field, unrecognized $schema version, a name outside §5.5). Exits 1.
  • TOLERATED — a conformant client reports the condition and keeps loading (unknown top-level field §5.2, non-object extensions §8.1). Reported, exits 0.
  • INFO — context only. One exists: alongside a §5.5 name violation, mcp-vet notes that the official schema's negative-lookahead name pattern cannot compile under RE2, so Go-based validators fail at schema compile time instead of reporting the name (agent-plugins-spec#76).

Every envelope finding also carries the 1.0.0 spec section it cites — in the terminal (§5.2 next to the rule id), the JSON report, and SARIF properties.section. And per §8.1's "MUST ignore manifest entries for namespaces it does not implement without validating the contents of their values", nothing inside extensions is validated at all — an unmodelled namespace containing anything produces zero findings.

npx @booyaka/mcp-vet plugin ./my-plugin

vets the whole package in one pass:

  1. Envelope. plugin.json and mcp.json against the canonical 1.0.0 schemas, vendored under schemas/agent-plugins/1.0.0/ (fetched 2026-08-18 from plugin.schema.json and mcp.schema.json; the skill-layout prose is pinned verbatim in skill-layout.md). Validation is offline, which the spec requires: clients MUST NOT retrieve a schema while loading a plugin.
  2. Semantics the schema can't express. Single-token stdio commands, cwd containment (./../x passes the schema's prefix pattern but escapes the root), reserved env names, and the URL security rules.
  3. The protocol inside the envelope. A stdio server whose command is a ./-relative path into the plugin gets its bundled TS/JS/Python source scanned with the same 22 static rules as mcp-vet <paths>, reported plugin-relative with file:line:col. Servers that can't be scanned are never silently skipped: a bare launcher token (npx, uvx, node, python) is reported as unscannable by design with the reason printed, and remote entries point you at mcp-vet probe <url>.

The new rules

Rule Tier Fires when
PLUGIN_MANIFEST_INVALID 🔴 FATAL plugin.json violates a requirement conformant clients reject: absent manifest or unparsable JSON (§5.1), a missing/wrong-typed/empty required field (§5.3), an unrecognized $schema version (§5.2), or a name breaking §5.5's 1–64-char lowercase pattern (no -- or ..)
PLUGIN_UNKNOWN_FIELD 🔵 TOLERATED an unknown top-level manifest field. §5.2 makes clients report and ignore it and keep loading, so mcp-vet reports it and exits 0. A field named skills or mcpServers gets a note that those components will not load (§6.1 discovers them from skills/ and mcp.json only)
PLUGIN_EXTENSIONS_NOT_OBJECT 🔵 TOLERATED extensions is a string, number, array or null. §8.1 makes clients report and ignore the field and continue loading components. Fires exactly once, never per interior key
PLUGIN_NAME_RE2_LOOKAHEAD ⚪ INFO rides along with a §5.5 name violation: the official schema's negative-lookahead pattern does not compile under RE2, so Go-based validators error out at schema compile time instead of reporting the name (spec issue #76)
PLUGIN_MCP_INVALID 🔴 BREAKING mcp.json fails the 1.0.0 MCP schema. The root must be exactly $schema + mcpServers, and each server must match exactly one closed variant (stdio, streamable-http, sse). Unknown types, cross-variant fields, and containment failures land here
PLUGIN_CMD_NOT_SINGLE_TOKEN 🔴 BREAKING a stdio command is not a single executable token, bare or beginning with ./. Clients don't shell-split: "node server.js" is looked up as an executable literally named node server.js
PLUGIN_CWD_ESCAPE 🔴 BREAKING cwd doesn't start with ./, ${PLUGIN_ROOT} or ${PLUGIN_DATA}, or starts with ./ but resolves outside the plugin root
PLUGIN_ENV_RESERVED 🔴 BREAKING env contains an entry named PLUGIN_ROOT or PLUGIN_DATA. The spec reserves both, and such an entry makes the server configuration invalid
PLUGIN_REMOTE_INSECURE_URL 🔴 BREAKING a streamable-http/sse url is not an absolute HTTP(S) URL, carries user information or a fragment, or uses plain HTTP on a non-loopback host. Loopback means exactly localhost, 127.0.0.0/8, or [::1]. The spec says non-loopback, not non-localhost, so http://127.0.0.1:3000/mcp and http://[::1]:3000/mcp are fine
PLUGIN_SSE_TRANSPORT 🟡 DEPRECATED any server declares type: "sse", the HTTP+SSE transport the MCP 2026-07-28 spec reclassifies as Deprecated (SEP-2596; the source-side counterpart is SSE_TRANSPORT_DEPRECATED)
PLUGIN_SKILL_LAYOUT 🟡 DEPRECATED a SKILL.md sits anywhere other than skills/<name>/SKILL.md. Clients MUST NOT recurse deeper, so a nested skill isn't an error. It's silently invisible, which is worse

Worked example

Given a plugin whose mcp.json is:

{
  "$schema": "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json",
  "mcpServers": {
    "legacy": { "type": "sse", "url": "http://example.com/mcp" },
    "local": { "type": "stdio", "command": "node server.js", "cwd": "../shared" }
  }
}

mcp-vet plugin ./my-plugin reports (fixture-locked in test/fixtures/plugins/worked-example):

mcp.json:5:7   DEPRECATED  PLUGIN_SSE_TRANSPORT         legacy: type "sse" is the HTTP+SSE transport, Deprecated by MCP 2026-07-28 (SEP-2596)
mcp.json:6:7   BREAKING    PLUGIN_REMOTE_INSECURE_URL   legacy: non-loopback host "example.com" over plain HTTP — non-loopback endpoints MUST use HTTPS
mcp.json:10:7  BREAKING    PLUGIN_CMD_NOT_SINGLE_TOKEN  local: "node server.js" is 2 tokens — clients do not shell-split, so pass arguments via "args"
mcp.json:11:7  BREAKING    PLUGIN_CWD_ESCAPE            local: "../shared" does not start with ./, ${PLUGIN_ROOT} or ${PLUGIN_DATA}

4 finding(s): 3 BREAKING, 1 DEPRECATED   → exit 1

And the TOLERATED side, on the manifest shape reported in dotnet/skills#1087 (fixture-locked in test/fixtures/plugins/dotnet-1087) — plugin.json declaring "skills": [] and "mcpServers": {} at the top level:

plugin.json:4:3  TOLERATED  PLUGIN_UNKNOWN_FIELD §5.2 [high]
    unknown top-level field "skills" — a conformant client reports this and continues loading;
    note: skills declared here will NOT load — §6.1 discovers skills only from the skills/ directory
plugin.json:5:3  TOLERATED  PLUGIN_UNKNOWN_FIELD §5.2 [high]
    unknown top-level field "mcpServers" — a conformant client reports this and continues loading;
    note: MCP servers declared here will NOT load — §6.1 discovers MCP servers only from mcp.json at the plugin root

2 finding(s): 2 TOLERATED   → exit 0

Before 0.13.0 those were two schema violations and an exit 1 — mcp-vet said the plugin was broken while every conformant client loads it.

Edge cases it gets right

  • A missing mcp.json is valid and silent (spec §6.2: an absent component location MUST NOT be treated as an error), and mcpServers may legally be empty.
  • A reverse-domain top-level directory such as com.github.copilot/ is a legal client-extension directory and is ignored, not flagged.
  • A $schema pinning a version mcp-vet does not know stays FATAL — §5.2 makes clients reject an unrecognized version, so that is not a tolerated drift.
  • extensions set to a string is TOLERATED exactly once, not once per interior key; an unmodelled namespace whose value is an object containing anything at all produces zero findings (§8.1).
  • A plugin bundling a non-source executable (say a compiled binary) as its server gets an explicit "can't audit this" note, not silence.

--json, --sarif, --fail-on, and the exit-code contract are shared with the scan: any FATAL or BREAKING finding exits 1; TOLERATED, DEPRECATED and INFO exit 0 (--fail-on any still fails on them); unusable input exits 2. In SARIF, TOLERATED and INFO map to level note.

Where mcp-vet fits (and where it doesn't)

The probe half is not novel, and this README won't pretend otherwise. Other tools already check a running server over the wire, and some of them already cover ground the --spec 2026-07-28 suite covers:

Tool What it is Overlap
@modelcontextprotocol/conformance — npx @modelcontextprotocol/conformance server --url <url> The official wire test suite. Its README notes that "dated versions through 2025-11-25 use the stateful lifecycle (initialize handshake), while the 2026 draft (2026-07-28) uses the stateless lifecycle (per-request _meta)" The authority on wire conformance. If you can boot your server, run it — it is more complete at the protocol level than any third-party probe, mcp-vet's included
mcp-spec-check (Roee-Tsur) Zero-install black-box URL probe for 2026-07-28 readiness Already ships cache-metadata, MRTR and resources-subscribe checks — genuinely prior art for three of mcp-vet's thirteen --spec checks
mcpfit (printemps-tokyo) Go CLI auditing a running server against the stateless spec Already ships a cache-hints check for ttlMs/cacheScope
@hiai-gg/agent-plugins-doctor (0.0.6, 2026-08-08) Agent Plugins envelope validator: plugin.json/mcp.json/SKILL.md against the 1.0.0 spec, secret/path-traversal auditing, a client compatibility matrix, 12 autofixes Real overlap on the envelope (PLUGIN_MANIFEST_INVALID/PLUGIN_MCP_INVALID territory). It says nothing about MCP protocol revisions, the SSE deprecation, or the server source a plugin bundles. That protocol-inside-the-envelope audit is what mcp-vet plugin adds

The uncontested claim is the other half: static source analysis. mcp-vet reads your source — TypeScript/JavaScript via ts-morph, Python via a bundled ast script — and reports file:line:col, SARIF, and --fix. That means:

  • It runs in CI on a pull request, before anything is deployed, without booting a server, provisioning a URL, or having a working build.
  • It points at the line to change, not at a wire symptom. A probe can tell you a result lacks resultType; only source analysis tells you src/handlers/tools.ts:142 is the return statement that omits it.
  • It fixes what is mechanical — --fix rewrites -32002 → -32602 and the three renumbered codes in place, with --dry-run to preview.
  • It covers code paths a probe never reaches — an error branch that fires once a month, a client-side session resume, a handler registered but not exercised by a smoke test.

The two halves are complements, not competitors. The honest recommendation: static scan in CI on every PR (mcp-vet), official conformance suite against a deployed instance before release. mcp-vet ships the probe so you can get a first signal without wiring up a second tool — not as a replacement for the official suite.

Usage

npx @booyaka/mcp-vet [paths...]        # scan directories and/or files (default: current directory)
npx @booyaka/mcp-vet . --fix           # scan, and auto-apply the mechanical -32002 → -32602 rewrite
npx @booyaka/mcp-vet ./src ./packages  # multiple roots
npx @booyaka/mcp-vet server.py         # a single file
npx @booyaka/mcp-vet fixtures ./dir    # write runtime conformance fixtures + checklist (default: ./mcp-vet-fixtures)
npx @booyaka/mcp-vet probe <url|cmd>   # vet a RUNNING server's wire behavior (see section above; alias: run)
npx @booyaka/mcp-vet plugin <dir>      # vet an Agent Plugins 1.0 package (envelope + bundled server source)

Globs **/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs} and **/*.py, skipping node_modules, .git, __pycache__, dist, and build.

Options

Flag Description
--github-annotations emit GitHub Actions ::error / ::warning annotations to stdout
--sarif [file] write a SARIF 2.1.0 report (default mcp-vet.sarif) for GitHub code scanning
--out-dir <dir> where to write mcp-vet-report.md / mcp-vet-results.json (default: cwd)
--no-files don't write the markdown/json report files
--only <ids> only run these rule ids, comma/space separated (spec, PY_SDK_V1_* or TS_SDK_V1_*)
--disable <ids> skip these rule ids
--fail-on <level> non-zero exit on breaking (default), any, or none
--fix auto-apply the safe mechanical fixes in place (currently -32002 → -32602)
--dry-run with --fix: print the rewrites that would be made, without changing files
--json print findings as a JSON array to stdout (pure JSON — notices go to stderr)
--min-confidence <level> report only findings at/above high, medium, or low (default)
--ignore <glob> ignore paths matching a gitignore-style glob (repeatable)
--max-file-size <kb> skip files larger than N KB (default 1536; 0 = no limit)
--no-py-fallback disable the regex fallback used when no Python interpreter is found
--py-sdk <mode> Python SDK v1→v2 migration rules: auto (default; reads the declared mcp specifier), v1, or v2
--no-py-sdk disable the PY_SDK_V1_* rule group entirely (pre-0.12.0 output)
--ts-sdk <mode> TypeScript SDK v1→v2 migration rules: auto (default; reads the declared @modelcontextprotocol packages), v1, or v2
--no-ts-sdk disable the TS_SDK_V1_* rule group entirely (pre-0.14.0 output)
--config <path> path to a config file (see below)
--color / --no-color force or disable colored output
--quiet suppress the human-readable terminal report
-v, --version print version

Suppressing findings inline

Recognized in any comment style (// or #):

const x = -32002; // mcp-vet-disable-line ERROR_CODE_32002
// mcp-vet-disable-next-line
const y = 'Mcp-Session-Id';
  • mcp-vet-disable-line [IDS] — suppress on the same line.
  • mcp-vet-disable-next-line [IDS] — suppress on the following line.
  • mcp-vet-disable-file — suppress the whole file.

Omitting the ids suppresses all rules on that line/file; listing ids (e.g. ERROR_CODE_32002) suppresses only those. Any rule id works, including the PY_SDK_V1_* and TS_SDK_V1_* groups. An id the parser does not recognize is ignored, so a typo falls back to suppressing everything on the line — check mcp-vet --only <id> if a suppression is wider than you meant.

Config file

Drop a .mcpvetrc.json (or mcp-vet.config.json) in your project root; CLI flags override it.

{
  "ignore": ["**/generated/**", "vendor/"],
  "disable": ["LOGGING_CAP"],
  "failOn": "breaking",
  "minConfidence": "medium",
  "maxFileSizeKb": 2048,
  "pythonFallback": true,
  "pySdk": "auto",
  "tsSdk": "auto"
}

only / disable accept any rule id, including the PY_SDK_V1_* and TS_SDK_V1_* groups. A JSON Schema for the file ships as schema/mcpvetrc.schema.json — point $schema at it for editor autocomplete.

You can also list ignore globs one-per-line in a .mcpvetignore file.

Outputs

  1. Terminal — compiler-style file:line:col, red for BREAKING, yellow for DEPRECATED, grouped by file, with before/after snippets and a [confidence] tag.
  2. mcp-vet-report.md — a Markdown table (File · Line · Pattern · Severity · Confidence · Explanation).
  3. mcp-vet-results.json — a structured JSON array of every finding (line, column, confidence, docUrl, before/after, source analyzer, and section — the Agent Plugins 1.0.0 spec section on envelope findings, null elsewhere).
  4. --github-annotations — native GitHub Actions annotations that surface inline on the PR diff.
  5. --sarif — SARIF 2.1.0 for GitHub Advanced Security "code scanning" (uploads via github/codeql-action/upload-sarif).

Exit codes

  • 0 — clean, only DEPRECATED findings, or --fail-on none.
  • 1 — findings that trip --fail-on (BREAKING by default).
  • 2 — operational error (bad path, unreadable config, invalid flag/rule id).

Use it in CI

# .github/workflows/mcp-vet.yml
name: mcp-vet
on: [push, pull_request]
jobs:
  vet:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-node@v7
        with: { node-version: '20' }
      - run: npx @booyaka/mcp-vet . --github-annotations

setup-node runners already include Python 3, which mcp-vet uses to scan .py files. If no interpreter is found, it automatically falls back to a regex scanner (reduced precision) unless you pass --no-py-fallback; TypeScript/JavaScript scanning is unaffected either way.

To upload results to GitHub code scanning instead:

      - run: npx @booyaka/mcp-vet . --sarif mcp-vet.sarif --fail-on none
      - uses: github/codeql-action/upload-sarif@v4
        with: { sarif_file: mcp-vet.sarif }

Local git hooks

Catch it before it reaches CI. With husky + lint-staged:

// package.json
{
  "lint-staged": {
    "*.{ts,tsx,js,jsx,mjs,cjs,py}": "mcp-vet"
  }
}

Or with pre-commit (Python projects):

# .pre-commit-config.yaml
repos:
  - repo: local
    hooks:
      - id: mcp-vet
        name: mcp-vet
        entry: npx @booyaka/mcp-vet
        language: system
        files: \.(ts|tsx|js|jsx|mjs|cjs|py)$

Why there's no --baseline

Some linters let you "grandfather" existing findings so CI stays green. mcp-vet deliberately doesn't: this is a one-time migration to a spec that ships on a fixed date, and a suppressed finding is code that will break on July 28. The point is for the build to fail until it's actually fixed. For the rare intentional exception, use targeted inline suppression — an explicit, reviewable, per-line decision.

Large repositories

mcp-vet skips node_modules, .git, dist, build, and __pycache__ by default, chunks the Python subprocess, and takes --max-file-size. On a big monorepo, scope the scan to the packages that ship MCP servers (mcp-vet ./packages/server ./services/mcp) and add --ignore globs for generated code.


How it works

  • TypeScript / JavaScript — parsed with ts-morph; the analyzer walks the AST and emits normalized tokens (string literals, signed numeric literals, identifiers, object keys) annotated with structural capability context and registration context.
  • Python — a bundled script (dist/python/mcp_ast_scan.py) runs ast.parse + a context-tracking walk in a subprocess (chunked for large repos) and emits the same token shape (with character-accurate columns). When no interpreter exists, a regex fallback covers the deterministic rules.
  • A single rule engine applies all 22 protocol rules to those tokens, so TS and Python behave identically. Findings are de-duplicated per (line, column, rule) and can be suppressed inline. The two SDK-migration groups run alongside it, gated on the declared SDK version, and de-duplicate per (line, rule) — one import list is one thing to fix.

It matches the ways real servers are actually written, not just raw method strings:

  • literal method strings — 'tasks/list', 'sampling/createMessage', 'logging/setLevel', …
  • SDK schema-constant registration — server.setRequestHandler(InitializeRequestSchema, …) (how the official SDKs register handlers) maps InitializeRequestSchema, ListRootsRequestSchema, CreateMessageRequestSchema, SetLevelRequestSchema, ListTasksRequestSchema, GetTaskResultRequestSchema, … to the right rule.
  • SDK capability constructors — the Python SDK's ClientCapabilities(roots=RootsCapability()) is recognized structurally (high confidence), and RootsCapability / SamplingCapability / LoggingCapability are matched directly.
  • sessionIdGenerator — flagged only when it's a real generator, not the migrated sessionIdGenerator: undefined.
  • aliased imports — import { InitializeRequestSchema as Init } (TS) and from mcp.types import RootsCapability as RC (Python) are resolved back to their canonical names, so both the import line and the aliased usage sites are flagged. Namespace access (types.InitializeRequestSchema) is matched too.
  • client-side session ownership — a client transport constructed with a real sessionId/session_id, or a read of transport.sessionId; the migrated sessionId: undefined / session_id=None forms are recognized as benign.

Measured, not vibes: scanned against the official MCP reference servers and both SDK example suites at pinned commits — 447 files / ~44k LOC — findings labeled against source: 258 findings, 256 true positives, 2 false positives (0.8%) with the 22-rule engine (the v0.4.0 9-rule run was 105/104/1 on the same corpus; the jump is the final removals firing on the SDKs' own pre-final examples). The three auth-hardening rules contribute zero findings here — correctly, since the SDK examples are compliant — so their behaviour is proven by fixtures instead. (v0.10.0 reported 247/244/3 by miscounting three DCR findings as true positives; 0.10.1 fixed that rule and 0.10.2 the remaining FP — see the correction note in BENCHMARK.md.) Corpus, commit SHAs, per-pattern counts, labeled negatives, and the recall discussion are in BENCHMARK.md.

Known limitations

These are locked into the test suite as test/fixtures/adversarial/missed/ — fixtures asserted to produce zero findings, so the claims below can't silently rot in either direction:

  • Split/computed method strings — "tasks" + "/list", `tasks/${op}`, or f"tasks/{x}" are not reconstructed.
  • Computed capability keys — { ['roo'+'ts']: {} } never exists as a single token.
  • Generated/loop-driven registration — method tables assembled from string fragments at runtime.
  • Framework-adapter indirection — routes built dynamically (app.post('/rpc/' + ns + '/' + action, ...)).
  • Cross-module renames — a wrapper module re-exporting an SDK constant under a new name is flagged in the wrapper file, but a consumer importing only the new name scans clean on its own. Scan whole projects, not single files.
  • Python SDK decorator/method registration — a handler wired purely as @server.list_roots() or a bare session.list_roots() call (with no capability declaration or method string in the file) is not matched, to avoid false positives on generic method names. The capability declaration in the same server is normally caught.
  • Auth helper indirection — when both the code redemption and the iss validation live inside a third-party helper (oauth.authorizationCodeGrantRequest(...)), the file contains no authorization_code/grant_type/iss token and AUTH_ISS_UNVALIDATED can neither fire nor verify (missed/auth-helper-indirection.ts).
  • Computed credential-store keys — store.set(key_for(server_url), creds) is skipped rather than guessed at, even when the computed key is in fact a server URL (missed/computed_cred_key.py).
  • Dynamic transport selection — mcp.run({ transport }) where the name comes from a variable or environment never puts the literal 'sse' under the transport key, so SSE_TRANSPORT_DEPRECATED cannot fire (missed/dynamic-transport.ts). Template-literal SSE frames (res.write(`event: endpoint…${id}`)) are likewise invisible to the string tokenizer — write the frame name as a plain literal or catch it at runtime with probe --spec (legacy-sse-transport).
  • The regex fallback (no Python interpreter) covers only the deterministic rules at reduced precision (the auth-hardening rules need the AST analyzers); install Python for full .py fidelity.
  • Two v1→v2 changes deliberately have no rule. The deprecated runtime APIs (Server.createMessage / listRoots / sendLoggingMessage, Client.setLoggingLevel / sendRootsListChanged, registerClient) are @deprecated in v2 but, in the guide's words, "still fully functional" with their v1 signatures — and ROOTS_CAP / SAMPLING_CAP / LOGGING_CAP already cover the underlying deprecation. And the second argument to client.request(req, ResultSchema) is still valid v2 for custom methods and passthroughs, so a rule on it would fire on correct code.
  • TS_SDK_V1_ZOD3 anchors on a zod import. A project on a below-floor zod range whose SDK files never import zod directly gets no finding, the same shape as PY_SDK_V1_HTTPX. Check the declared range yourself if no file imports zod.
  • TS_SDK_V1_VARIADIC_REG matches x.tool( / .prompt( / .resource( with two or more arguments in a file that imports the SDK. An unrelated agent.tool(a, b) in such a file reports at medium; a receiver whose name mentions server or mcp reports at high. Filter with --min-confidence high if a codebase mixes agent frameworks.
  • A lockfile that names an MCP package only transitively decides the gate when package.json names none. That is deliberate — a file importing the SDK really is using whatever version resolved — but --ts-sdk overrides it.

This is the recall boundary of static analysis: it proves known patterns are absent, not that the server speaks the new wire contract. Cover the difference with the runtime conformance fixtures.

Programmatic API

The scanner is usable as a library (typed) as well as a CLI — for editor extensions, custom CI steps, or migration harnesses:

import { scan, ALL_PATTERN_IDS, IgnoreMatcher, applyFixes } from '@booyaka/mcp-vet';

const result = scan(['./src'], {
  enabled: new Set(ALL_PATTERN_IDS),
  ignore: new IgnoreMatcher([]),
  maxFileSizeKb: 0,
  pythonFallback: true,
  minConfidence: 'low',
});

for (const f of result.findings) {
  console.log(`${f.file}:${f.line} ${f.severity} ${f.patternId}`);
}

// Apply the safe mechanical fixes:
applyFixes(result.findings);

The SDK-migration groups are opt-in from the library (the CLI turns them on):

import { scan, ALL_PATTERN_IDS, ALL_TS_SDK_RULE_IDS, IgnoreMatcher } from '@booyaka/mcp-vet';

const result = scan(['./src'], {
  enabled: new Set(ALL_PATTERN_IDS),
  ignore: new IgnoreMatcher([]),
  maxFileSizeKb: 0,
  pythonFallback: true,
  minConfidence: 'low',
  tsSdkMode: 'auto', // reads the declared @modelcontextprotocol packages
  tsSdkEnabled: new Set(ALL_TS_SDK_RULE_IDS),
});
console.log(result.tsSdkStatus); // { mode, evaluated, v1?, half?, undetermined }

Also exported: renderJson / renderMarkdown / renderSarif, the rule registries RULES / PY_SDK_RULES / TS_SDK_RULES / PLUGIN_RULES / RUNTIME_RULES, the detectors detectMcpSdk / detectTsSdk / classifySpecifier / npmRangeFloor / clearSdkDetectionCache, and the Finding / PatternId / PySdkRuleId / TsSdkRuleId / PySdkMode / TsSdkMode / Severity / Confidence types.

Requirements

  • Node.js ≥ 18
  • Python 3 (optional — only needed for full-precision .py scanning; python, py, or python3 on PATH)

Development

npm install      # installs deps and builds (via prepare)
npm run build    # tsc -> dist/ + copies the Python script
npm test         # builds, then runs the Node.js built-in test runner (105 tests)

Test fixtures live in test/fixtures/ (dirty TS + Python servers including dirty/ with one instance of every final-changelog pattern, a clean/ server with zero violations, negatives/ true-negatives incl. the plain-OAuth pair locking the auth-rule context gate, an auth/ worked example + its migrated twin, an sse/ directory covering the deprecated HTTP+SSE transport in both SDKs plus the hand-rolled two-endpoint shape, a confidence/ gradient, and suppress/ cases). Runtime-probe fixtures live in test/probe-fixtures/ — minimal stdio + Streamable-HTTP MCP servers: one returning draft-07 schemas, one requiring the initialize handshake, one fully migrated 2026-07-28-native (stateless + server/discover + -32602), a server-partial.mjs with one deliberate migration defect per mode (legacy-error-code / no-discover / bad-discover), and the HTTP fixture's auth-legacy / auth-migrated modes serving RFC 8414 metadata for the two auth checks.

License

MIT — see LICENSE.

About

Scan MCP server source for patterns that break under the 2026-07-28 Model Context Protocol spec.

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages