Title: Work through the spec-conformance gaps recorded by the interaction suite by maxisbey · Pull Request #2999 · modelcontextprotocol/python-sdk · GitHub
Open Graph Title: Work through the spec-conformance gaps recorded by the interaction suite by maxisbey · Pull Request #2999 · modelcontextprotocol/python-sdk
X Title: Work through the spec-conformance gaps recorded by the interaction suite by maxisbey · Pull Request #2999 · modelcontextprotocol/python-sdk
Description: The tests/interaction/ suite is requirement-tagged against the MCP specification. Where the SDK's behaviour fell short of the spec, the suite did not skip or xfail: it pinned the actual behaviour and recorded the gap on the requirement entry. This PR is a burn-down of those recorded gaps. It fixes fourteen of them in code (and deletes a fifteenth whose behaviour had already been fixed but whose record had gone stale), narrows three more to a precisely-stated residual, records two as deliberate, ecosystem-consistent choices, and closes three long-standing upstream issues along the way. It spans the server's bearer-token gate, the bundled OAuth authorization server, the OAuth client's use of discovered metadata, the JSON-RPC error surface for cancellation / handler crashes / unhandled client callbacks, the initialize handshake, an opt-in client-side capability pre-check, the explicit-token progress API, and the elicitation requested-schema type. For every behaviour change, the existing requirement-tagged test flips from pinning the gap to pinning the spec behaviour, so the suite stays the source of truth for what conforms and what does not. Commits Commit / subject What it closes, by behaviour 68e4eb28 Tighten requirements-catalog divergence notes to match current SDK behaviour Bookkeeping only — no runtime change. Corrects four requirement entries whose recorded-gap notes had gone stale (one behaviour was already fixed; three were re-scoped or re-classified as intentional, cross-SDK-consistent choices). bad42e28 Emit RFC 6750 scope= in WWW-Authenticate and validate token audience The resource server's 401/403 challenges now advertise the required scopes via scope= (which the SDK client already reads to drive step-up); a request with no credentials gets a bare Bearer challenge instead of error="invalid_token" (RFC 6750 §3.1 says the error attribute SHOULD NOT appear when nothing was presented); and a token whose RFC 8707 audience names a different server is now rejected 401 invalid_token. 47639a2b Stop replying to cancelled requests; map unhandled handler exceptions to -32603 A request cancelled via notifications/cancelled now gets no response, per the spec's SHOULD (it was answered with code: 0). An unhandled exception in a request handler now produces JSON-RPC -32603 (INTERNAL_ERROR) with an opaque message instead of code: 0 carrying str(exc) — handler internals no longer leak to the peer; the exception is still logged server-side. raise MCPError(...) for a specific code is unchanged. a1734460 Reject bearer tokens that carry no audience claim Closes the hole the previous commit left: a verified token with no resource indicator at all was still accepted. When resource_server_url is configured the gate now fails closed (401 invalid_token). AuthSettings(verifier_validates_audience=True) opts out for verifiers that enforce aud themselves and cannot surface the claim; RefreshToken.resource is added so the audience binding survives a refresh. The SDK's own tutorials and example servers were teaching the bug and are fixed in the same commit. 884badc9 Use spec error codes for unhandled elicitation/create and roots/list When a client has no elicitation_callback / list_roots_callback, the SDK still answers the server on its behalf. Those answers now use the codes the spec assigns — -32602 for elicitation/create (a client with no callback declared no modes, and a request for an undeclared mode MUST be answered -32602) and -32601 for roots/list — instead of -32600 for both. Messages unchanged; sampling stays at -32600 (the spec assigns no code there). 24061fe9 Reject non-HTTPS, non-loopback redirect URIs at client registration The bundled authorization server's /register accepted any well-formed URL as a redirect_uris entry — cleartext http:// on a non-loopback host, javascript:, data:, fragment-carrying URIs. It now rejects anything that is not HTTPS or a loopback host (localhost, 127.0.0.1, [::1]) with 400 invalid_client_metadata, and rejects fragments (OAuth 2.1 §2.3). Separately, /token now answers an authorization-code exchange whose redirect_uri does not match the one used at /authorize with error=invalid_grant (RFC 6749 §5.2) instead of invalid_request. Closes #2629. 3eb352c8 Align OAuth client with spec on PKCE verification and scope selection (a) The client now verifies PKCE support before starting an authorization-code grant, as the spec's Authorization Code Protection section requires: if AS metadata was discovered and its code_challenge_methods_supported is absent or omits S256, the flow raises OAuthFlowError instead of redirecting. (b) The client stops reading the authorization server's scopes_supported as a scope fallback — that list is a superset of any one resource's, so the SDK over-requested and triggered real access_denied failures. The spec's chain is WWW-Authenticate scope= → PRM scopes_supported → omit. Closes #1307. 1e2bd9be Reject a second initialize on an already-initialized session A repeated initialize on a live connection was answered as a fresh handshake, silently overwriting the session's recorded client_params and negotiated protocol version — so a later check_capability answered against the second client's capabilities. It is now rejected -32600 ("Session already initialized") and the established session keeps serving. No compliant client is affected (ClientSession.initialize() is already idempotent). Legacy-only: 2026-07-28 removes initialize. Closes #2605. 8f60ebe5 Make the prompt test's opaque coverage workaround explicit Test-only: a two-line comment explaining a deliberately non-obvious shape in one prompt test, so the next reader does not "fix" it into a coverage violation. 28f500c7 Add an opt-in strict_capabilities flag to Client and ClientSession Additive, default-off. Client(..., strict_capabilities=True) rejects, before the request reaches the transport, a call to a method whose required server capability the connected server did not advertise — e.g. list_resources() against a server that only advertised tools, or subscribe_resource() when the server's resources capability does not set subscribe. The rejection is an MCPError with -32601 (METHOD_NOT_FOUND), the same code a compliant server returns for an unadvertised capability, so opting in changes where the rejection happens, not what callers catch. Mirrors the TypeScript SDK's enforceStrictCapabilities (also default-off); the same keyword-only parameter exists on ClientSession. Because the gate reads the negotiated server capabilities, combining it with a bare version pin (where the client never learns them) is refused at construction with a ValueError that names the fix. The method-to-capability table is exported as mcp_types.methods.SERVER_CAPABILITY_REQUIREMENTS, and the lifecycle capability-rule requirement entry is no longer marked untested. eddfa29d Deprecate ServerSession.send_progress_notification send_progress_notification takes an explicit progress token decoupled from any request's lifetime, so it can keep emitting progress for a request that has already completed — which the spec forbids ("Progress notifications MUST stop after completion"). It is deprecated in favour of the request-scoped Context.report_progress() / ServerSession.report_progress(), which reports against the inbound request's own token, no-ops when the caller did not ask for progress, and is closed with the request. The deprecated method keeps working and emits MCPDeprecationWarning. To make "stops when the request completes" hold on every dispatcher, the in-process direct dispatcher now closes its context with the request the way the JSON-RPC one already did. 3605ec09 Type the elicitation requested schema on the send side ElicitRequestedSchema was a TypeAlias for dict[str, Any]; it is now a Pydantic model of the spec's restricted requested-schema subset, backed by a new PrimitiveSchemaDefinition union. ServerSession.elicit_form() (and the deprecated elicit() alias) and ClientPeer.elicit_form() accept only this model, so a nested-object property, an array-of-objects property, or an anyOf union is unconstructible at the only place a server author supplies a schema, rather than silently forwarded to the client — the spec restricts form-mode requested schemas to flat objects with primitive-typed properties ("complex nested structures, arrays of objects … are intentionally not supported"). The high-level Context.elicit() / elicit_with_validation() path is unchanged. Inbound is deliberately untouched: ElicitRequestFormParams.requested_schema stays a plain dict[str, Any] on the wire, so older servers that emit anyOf for Optional form fields still reach the client's elicitation callback. c2b3e8ee Record two divergences as intentional, ecosystem-consistent choices Bookkeeping only — no code or test behaviour change. Two interaction-suite divergence notes described their gaps as unenforced spec MUSTs without saying whether closing them was planned; both are deliberate and the notes now say so. (1) stdio_server does not redirect sys.stdout, so a handler print() corrupts the protocol stream — no MCP SDK redirects stdout, and a redirect would only catch print(), not os.write(1, ...) or C extensions writing to fd 1, so it would be a partial guard rather than a structural fix; the logging tutorial already tells stdio server authors to log to stderr. (2) No MCP SDK validates sender-side progress monotonicity; that MUST is a contract on the handler author, not on the transport, and the test pins the unvalidated pass-through. Motivation These are all places where the SDK was provably out of step with normative spec text (or an RFC the spec defers to). Three of them are reported user-facing bugs (#2629, #1307, #2605); the auth ones matter most — a resource server that accepts audience-unbound tokens, or a built-in authorization server that registers javascript: redirect URIs, is the kind of default a user has no way to notice. Working through them as one burn-down keeps the requirement manifest honest: a gap that is fixed loses its record, a gap that is partially closed gets a record stating exactly the residual, and a gap that is a deliberate choice says so — so what remains recorded is real and current. Tested ./scripts/test (the full suite under the 100% line+branch coverage gate plus strict-no-cover) is green at HEAD: 4303 passed, 7 skipped, 1 xfailed (the one xfail is pre-existing and unrelated — a Pydantic AnyUrl trailing-slash quirk in tests/client/test_auth.py), coverage 100.00%. pyright reports 0 errors; ruff check / ruff format are clean; the new-suppression audit (git diff ... | grep -E '^\+.*(pragma|type: ignore|noqa)') is empty. Every behaviour change flips an existing requirement-tagged interaction test rather than only adding a new one, so the old behaviour cannot silently come back. New tests cover the fail-closed audience gate (including the verifier_validates_audience opt-out and the refresh chain), the loopback/fragment matrix at /register, the PKCE-refusal arms (absent field, list without S256, no-metadata leniency), the double-initialize rejection on stdio, stateful HTTP, and stateless HTTP, the strict_capabilities pre-wire rejection alongside the default send-and-surface behaviour, the request-scoped progress close on both dispatchers, and the typed ElicitRequestedSchema construction/rejection matrix and its to_wire() round-trip. Breaking changes All documented in docs/migration.md (one section each): Bearer tokens are rejected unless their audience names this server — including tokens that carry no resource indicator at all. Populate AccessToken.resource in your TokenVerifier (recommended; the examples now show it), or set AuthSettings(verifier_validates_audience=True) if your verifier already enforces aud and cannot surface it. resource_server_url=None still disables the check. Bundled authorization server: RFC-correct redirect-URI handling — /register rejects non-HTTPS, non-loopback, and fragment-carrying redirect URIs with invalid_client_metadata (this also rejects RFC 8252 private-use schemes such as com.example.app:/callback; MCP allows only HTTPS or loopback); /token answers a redirect-URI mismatch with invalid_grant instead of invalid_request. OAuth client refuses to authorize when AS metadata does not advertise S256 PKCE — OAuthFlowError instead of proceeding. No SDK-side opt-out: an authorization server that supports S256 but omits the field needs its published metadata fixed (RFC 8414 §2). OAuth client no longer reads scopes_supported from authorization-server metadata to choose a scope — if that was your only scope source, the client now omits scope entirely; pass an explicit scope on OAuthClientMetadata to keep the old request. Unhandled handler exceptions return -32603; cancelled requests get no reply — code that matched on the previous code: 0 responses must update; raise MCPError for a specific code, and note the exception text is now logged, not sent. Unhandled elicitation/create returns -32602; unhandled roots/list returns -32601 — server code that branched on error.code == -32600 to detect a client without these should switch codes, or (better) check the client's declared capabilities before sending. A second initialize on an already-initialized session is rejected with -32600 ("Session already initialized"). A peer that needs a fresh handshake opens a new connection (on streamable HTTP, a POST without Mcp-Session-Id). ServerSession.elicit_form() (and the deprecated elicit() alias) and ClientPeer.elicit_form() now take a typed mcp_types.ElicitRequestedSchema, not an arbitrary dict[str, Any] — ElicitRequestedSchema is the model now, no longer a TypeAlias for dict[str, Any]. Build it directly (ElicitRequestedSchema(properties={"x": StringSchema(type="string")}, required=["x"])) or validate an existing JSON Schema dict with ElicitRequestedSchema.model_validate(...). Schemas with nested-object properties, array-of-objects properties, or anyOf unions are rejected at construction instead of being forwarded. The high-level Context.elicit() / elicit_with_validation() path and the inbound/wire representation are unchanged. ServerSession.send_progress_notification() is deprecated in favour of the request-scoped Context.report_progress() / ServerSession.report_progress(). The method still works, but every call now emits mcp.MCPDeprecationWarning — so test suites that run with filterwarnings = ["error"] (or -W error) will start failing on it. report_progress uses the inbound request's own token and cannot emit progress after that request has completed, which is what the spec requires. Not a breaking change, but called out for completeness: Client(..., strict_capabilities=) (and the same keyword on ClientSession) is additive and opt-in. The default is False and the default behaviour is byte-for-byte unchanged — every request is still sent and the server's answer is still surfaced. Closes Closes #2629 Closes #1307 Closes #2605 AI Disclaimer
Open Graph Description: The tests/interaction/ suite is requirement-tagged against the MCP specification. Where the SDK's behaviour fell short of the spec, the suite did not skip or xfail: it pinned the actual behavio...
X Description: The tests/interaction/ suite is requirement-tagged against the MCP specification. Where the SDK's behaviour fell short of the spec, the suite did not skip or xfail: it pinned the actual beh...
Opengraph URL: https://github.com/modelcontextprotocol/python-sdk/pull/2999
X: @github
Domain: github.com
| route-pattern | /:user_id/:repository/pull/:id/files(.:format) |
| route-controller | pull_requests |
| route-action | files |
| fetch-nonce | v2:0c6aea53-9251-7cd6-ce72-04982e66da1d |
| current-catalog-service-hash | ae870bc5e265a340912cde392f23dad3671a0a881730ffdadd82f2f57d81641b |
| request-id | 986C:15E77E:9B9E50:D0F3D5:6A593911 |
| html-safe-nonce | 2c5e16386a1db0149245c951fd5c15a4f3253aacc87084fc5568a02fcde5b6b0 |
| visitor-payload | eyJyZWZlcnJlciI6IiIsInJlcXVlc3RfaWQiOiI5ODZDOjE1RTc3RTo5QjlFNTA6RDBGM0Q1OjZBNTkzOTExIiwidmlzaXRvcl9pZCI6IjEzNDY0NDczOTcyNzc2NzgyNSIsInJlZ2lvbl9lZGdlIjoiaWFkIiwicmVnaW9uX3JlbmRlciI6ImlhZCJ9 |
| visitor-hmac | 35c09ee7865ceef83e6d80c2d499d235eeba3ee405a447505fac46d418315993 |
| hovercard-subject-tag | pull_request:3942876760 |
| github-keyboard-shortcuts | repository,pull-request-list,pull-request-conversation,pull-request-files-changed,copilot |
| google-site-verification | Apib7-x98H0j5cPqHWwSMm6dNU4GmODRoqxLiDzdx9I |
| octolytics-url | https://collector.github.com/github/collect |
| analytics-location | / |
| fb:app_id | 1401488693436528 |
| apple-itunes-app | app-id=1477376905, app-argument=https://github.com/modelcontextprotocol/python-sdk/pull/2999/files |
| twitter:image | https://avatars.githubusercontent.com/u/224885523?s=400&v=4 |
| twitter:card | summary_large_image |
| og:image | https://avatars.githubusercontent.com/u/224885523?s=400&v=4 |
| og:image:alt | The tests/interaction/ suite is requirement-tagged against the MCP specification. Where the SDK's behaviour fell short of the spec, the suite did not skip or xfail: it pinned the actual behavio... |
| og:site_name | GitHub |
| og:type | object |
| hostname | github.com |
| expected-hostname | github.com |
| None | 1b31c9453e1607b2781513084b4770f67217e549834c85b76960d7b13626f7c8 |
| turbo-cache-control | no-preview |
| diff-view | unified |
| go-import | github.com/modelcontextprotocol/python-sdk git https://github.com/modelcontextprotocol/python-sdk.git |
| octolytics-dimension-user_id | 182288589 |
| octolytics-dimension-user_login | modelcontextprotocol |
| octolytics-dimension-repository_id | 862584018 |
| octolytics-dimension-repository_nwo | modelcontextprotocol/python-sdk |
| octolytics-dimension-repository_public | true |
| octolytics-dimension-repository_is_fork | false |
| octolytics-dimension-repository_network_root_id | 862584018 |
| octolytics-dimension-repository_network_root_nwo | modelcontextprotocol/python-sdk |
| turbo-body-classes | logged-out env-production page-responsive full-width |
| disable-turbo | true |
| browser-stats-url | https://api.github.com/_private/browser/stats |
| browser-errors-url | https://api.github.com/_private/browser/errors |
| release | 220f27779da5b92b5e17fcaef93e474cc8affba3 |
| ui-target | full |
| theme-color | #1e2327 |
| color-scheme | light dark |
Links:
Viewport: width=device-width