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.
- Final Key Changes list: https://modelcontextprotocol.io/specification/2026-07-28/changelog
- Deprecated-features registry: https://modelcontextprotocol.io/specification/2026-07-28/deprecated
- Release-candidate announcement: https://blog.modelcontextprotocol.io/posts/2026-07-28-release-candidate/
- Every rule's source sentence, pinned verbatim: docs/SPEC-2026-07-28.md
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 .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.)
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-25path and a2026-07-28path.mcp-vet fixturesemits 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.
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.)
| 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/setLevelandnotifications/roots/list_changedused to report as DEPRECATED warnings (exit 0) underLOGGING_CAP/ROOTS_CAP. The final changelog removes them — "Removeping,logging/setLevel, andnotifications/roots/list_changed" — so they now fail the build, while thelogging/rootscapability keys stay DEPRECATED. A test locks that split so it can't regress.
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 |
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
mcpentry, or a range like>=1.26that 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.
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.
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 $?
0One 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.
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 (theroots/sampling/loggingkey is really inside acapabilitiesobject), or aninitializestring used as a method name (handler registration,switchcase, orreq.method === 'initialize'). - medium — a
roots/sampling/loggingkey/string within 5 lines of acapabilitiesmention but not structurally verified; a realsessionIdGenerator; client-side session ownership (sessionId/session_idpassed to or read from a transport/client). - low — a bare
'initialize'string with no registration context.
"The
Mcp-Session-Idheader 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.
"The
initialize/initializedhandshake is removed. The protocol version, client info, and client capabilities that used to be exchanged once at connection time now travel in_metaon 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 ?? {};
}"The error code for a missing resource changes from the MCP-custom
-32002to the JSON-RPC standard-32602Invalid 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.
"A server can answer
tools/callwith a task handle, and the client drives it withtasks/get,tasks/update, andtasks/cancel. Anyone who shipped against the2025-11-25experimental 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."Remove
ping,logging/setLevel, andnotifications/roots/list_changed. Log level is now set per-request viaio.modelcontextprotocol/logLevelin_meta; servers MUST NOT emitnotifications/messagefor 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.
"Replace the HTTP GET endpoint and
resources/subscribe/resources/unsubscribewithsubscriptions/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']"Remove SSE stream resumability and message redelivery (the
Last-Event-IDheader 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).
"
-32000to-32019remains implementation-defined (existing SDK usage is grandfathered),-32020to-32099is 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.
"The
tasks/listmethod 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.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-MethodandMcp-Nameheaders 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$refURIs. The dialect half of this — schemas still declaring or using draft-07 forms — is detectable at runtime:mcp-vet probechecks 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.
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-fixtureswrites eleven ready-to-fire JSON fixtures plus a CHECKLIST.md, covering the runtime behaviors a linter cannot see:
server/discoverreplaces the initialize handshake- per-request
_meta(protocolVersion, clientInfo, capabilities) — including explicit refusal when_metais missing Mcp-Method/Mcp-Namerouting headers, including the header/body-mismatch rejection case- stateless auth context (no session-bound token cache)
- task-handle lifecycle: creation,
tasks/getpolling, resume on another instance,tasks/listandtasks/resultreturning method-not-found - duplicate request delivery (idempotency under retries)
- retry against a different server instance (no sticky in-memory state)
tools/listcache invalidation- downgrade/refusal: old-revision requests get an explicit error, never silent acceptance under the wrong semantics
subscriptions/listenopt-in: the client opts into specific types, the server acknowledges and tags notifications withio.modelcontextprotocol/subscriptionId, andresources/subscribenow answers-32601- MRTR: the server returns
resultType: "input_required"withinputRequests, and the client retries the original request carryinginputResponses
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).
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/mcpmcp-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.
| 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:
- Today:
mcp-vet probe <server>(unchanged) plus the static scan in CI. - When you start migrating: add a second CI job with
--spec-version 2026-07-28 --fail-on noneto see the new-spec violations without failing the build. - When your server targets
2026-07-28(e.g. after moving to@modelcontextprotocol/server2.x): drop--fail-on noneso the three ERROR-level checks gate the build. A correctly migrated server passes all of them; the pre-migration server failsrequires-initialize-handshakeandmissing-server-discoverimmediately. - Keep a
2025-11-25probe 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 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 stdioAgent 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
$schemaversion, 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-pluginvets the whole package in one pass:
- Envelope.
plugin.jsonandmcp.jsonagainst the canonical 1.0.0 schemas, vendored underschemas/agent-plugins/1.0.0/(fetched 2026-08-18 from plugin.schema.json and mcp.schema.json; the skill-layout prose is pinned verbatim inskill-layout.md). Validation is offline, which the spec requires: clients MUST NOT retrieve a schema while loading a plugin. - Semantics the schema can't express. Single-token stdio commands, cwd
containment (
./../xpasses the schema's prefix pattern but escapes the root), reserved env names, and the URL security rules. - The protocol inside the envelope. A stdio server whose
commandis a./-relative path into the plugin gets its bundled TS/JS/Python source scanned with the same 22 static rules asmcp-vet <paths>, reported plugin-relative withfile: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 atmcp-vet probe <url>.
| 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 |
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.
- A missing
mcp.jsonis valid and silent (spec §6.2: an absent component location MUST NOT be treated as an error), andmcpServersmay 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
$schemapinning a version mcp-vet does not know stays FATAL — §5.2 makes clients reject an unrecognized version, so that is not a tolerated drift. extensionsset 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.
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 yousrc/handlers/tools.ts:142is the return statement that omits it. - It fixes what is mechanical —
--fixrewrites-32002 → -32602and the three renumbered codes in place, with--dry-runto 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.
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.
| 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 |
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.
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.
- Terminal — compiler-style
file:line:col, red for BREAKING, yellow for DEPRECATED, grouped by file, with before/after snippets and a[confidence]tag. mcp-vet-report.md— a Markdown table (File · Line · Pattern · Severity · Confidence · Explanation).mcp-vet-results.json— a structured JSON array of every finding (line, column, confidence, docUrl, before/after, source analyzer, andsection— the Agent Plugins 1.0.0 spec section on envelope findings,nullelsewhere).--github-annotations— native GitHub Actions annotations that surface inline on the PR diff.--sarif— SARIF 2.1.0 for GitHub Advanced Security "code scanning" (uploads viagithub/codeql-action/upload-sarif).
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).
# .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-annotationssetup-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 }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)$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.
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.
- 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) runsast.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) mapsInitializeRequestSchema,ListRootsRequestSchema,CreateMessageRequestSchema,SetLevelRequestSchema,ListTasksRequestSchema,GetTaskResultRequestSchema, … to the right rule. - SDK capability constructors — the Python SDK's
ClientCapabilities(roots=RootsCapability())is recognized structurally (high confidence), andRootsCapability/SamplingCapability/LoggingCapabilityare matched directly. sessionIdGenerator— flagged only when it's a real generator, not the migratedsessionIdGenerator: undefined.- aliased imports —
import { InitializeRequestSchema as Init }(TS) andfrom 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 oftransport.sessionId; the migratedsessionId: undefined/session_id=Noneforms 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.
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}`, orf"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 baresession.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 noauthorization_code/grant_type/isstoken andAUTH_ISS_UNVALIDATEDcan 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 thetransportkey, soSSE_TRANSPORT_DEPRECATEDcannot 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 withprobe --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
.pyfidelity. - Two v1→v2 changes deliberately have no rule. The deprecated runtime APIs (
Server.createMessage/listRoots/sendLoggingMessage,Client.setLoggingLevel/sendRootsListChanged,registerClient) are@deprecatedin v2 but, in the guide's words, "still fully functional" with their v1 signatures — andROOTS_CAP/SAMPLING_CAP/LOGGING_CAPalready cover the underlying deprecation. And the second argument toclient.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_ZOD3anchors on azodimport. A project on a below-floor zod range whose SDK files never import zod directly gets no finding, the same shape asPY_SDK_V1_HTTPX. Check the declared range yourself if no file imports zod.TS_SDK_V1_VARIADIC_REGmatchesx.tool(/.prompt(/.resource(with two or more arguments in a file that imports the SDK. An unrelatedagent.tool(a, b)in such a file reports at medium; a receiver whose name mentionsserverormcpreports at high. Filter with--min-confidence highif a codebase mixes agent frameworks.- A lockfile that names an MCP package only transitively decides the gate when
package.jsonnames none. That is deliberate — a file importing the SDK really is using whatever version resolved — but--ts-sdkoverrides 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.
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.
- Node.js ≥ 18
- Python 3 (optional — only needed for full-precision
.pyscanning;python,py, orpython3onPATH)
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.
MIT — see LICENSE.
