Compare commits

...
Author SHA1 Message Date
Marcelo Trylesinski
6affe5c0d3
Detect event-loop blocking in tests (#3510)
Some checks failed
Conformance Tests / server-conformance (push) Has been cancelled
Conformance Tests / client-conformance (push) Has been cancelled
Deploy Docs / deploy-docs (push) Has been cancelled
CI / checks (push) Has been cancelled
GitHub Actions Security Analysis / zizmor (push) Has been cancelled
CI / all-green (push) Has been cancelled
2026-09-16 12:12:27 +02:00
Marcelo Trylesinski
9972c21aa4
Replace RootModel wrappers with type aliases and TypeAdapter validation (#3470)
Some checks failed
CI / checks (push) Has been cancelled
CI / all-green (push) Has been cancelled
2026-09-07 17:40:21 +02:00
Max
fd66270926
docs: refresh translations, and translate pages in parallel (#3458) 2026-09-07 13:35:53 +01:00
Max
08a3bc8eaf
docs: ask for AI disclosure on comments too (#3459) 2026-09-06 14:44:38 +01:00
Max
7bb486a10f
docs: stop presenting the in-memory client as the way to connect (#3443) 2026-09-05 04:15:58 +01:00
Max
0c9136841f
Add AuthSettings.validate_token_resource to check a bearer token's resource (#3447) 2026-09-05 03:35:55 +01:00
Max
9771e6b59c
Keep following a relative redirect when the endpoint URL carries userinfo (#3450) 2026-09-05 01:24:29 +01:00
Max
a925e55893
Bump the locked versions of eight dev and test dependencies (#3449) 2026-09-05 01:22:35 +01:00
dependabot[bot]
e8b94867ac
Bump pymdown-extensions from 11.0 to 11.0.1 (#3285)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-05 00:15:07 +01:00
Max
c6762e821c
Follow redirects only within the MCP endpoint's origin (#3397) 2026-09-04 23:43:38 +01:00
Max
5fd3abc548
Skip automatic docs previews for fork PRs and drop the setup-uv retry steps (#3445) 2026-09-04 21:56:58 +01:00
Max
a2dab7e2dd
Move the docs-preview workflow scripts out of the YAML into .github/scripts (#3446) 2026-09-04 21:30:04 +01:00
dependabot[bot]
2e0a18199a
Bump the github-actions group with 6 updates (#3424)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-04 20:17:00 +01:00
Max
10dc173637
Exercise the SEP-2575 stateless probes and SEP-2243 resource/prompt headers in the conformance fixtures (#3442) 2026-09-04 17:11:00 +01:00
Max
979208c115
Deprecate constructing the pre-provisioned OAuth clients without an issuer (#3435) 2026-09-04 15:53:21 +01:00
Max
d060b36e1d
Validate the authorization server metadata issuer on every discovery path (#3398) 2026-09-02 16:51:22 +01:00
Max
d2290ca343
Expire idle Streamable HTTP sessions by default and cap concurrent sessions (#3395) 2026-08-28 15:54:49 +01:00
Max
6705402e24
Resolve tool output-schema references within the schema document only (#3394) 2026-08-26 14:15:18 +01:00
Max
e7284ed130
Gate draft PRs too and rewrite the auto-close comment (#3378) 2026-08-26 12:02:43 +01:00
Max
0921d94a74
Point imports of mcp.server.fastmcp at the migration guide (#3388)
Some checks failed
CI / checks (push) Has been cancelled
CI / all-green (push) Has been cancelled
2026-08-25 16:39:57 +01:00
Max
4d6f87e8d2
Build releases with the pinned hatchling and a publish action that accepts Metadata 2.5 (#3380)
Some checks failed
CI / checks (push) Has been cancelled
CI / all-green (push) Has been cancelled
2026-08-24 19:57:07 +01:00
Max
c5d7d0b6d0
docs: refresh translations for recent English changes (#3379) 2026-08-24 19:29:54 +01:00
Max
d8b638304e
Give recursive tool return types an object-rooted output schema (#3376) 2026-08-24 19:06:22 +01:00
Max
56af44727b
Log MCPServer handler exceptions by kind and keep crash details off the wire (#3314)
Co-authored-by: Marcelo Trylesinski <marcelotryle@gmail.com>
2026-08-24 15:51:18 +01:00
Peter Alexander
f1c40b062f
Accept boolean sub-schemas in 2025-11-25 tool schema properties (#3354) 2026-08-24 15:41:59 +01:00
Max
57394b0548
Apply the request body limit to the SSE and OAuth endpoints (#3336) 2026-08-20 16:21:44 +01:00
Max
0cee6249ba
Hand TypedDict tool results to pydantic natively (#3331) 2026-08-20 15:36:21 +01:00
Marcelo Trylesinski
0d92192765
Shorten stdio test comments (#3329) 2026-08-18 10:50:47 +02:00
Max
b2025ab815
Acknowledge notification POSTs with 202 on the 2026-07-28 HTTP entry (#3326) 2026-08-17 21:15:35 +01:00
Max
9057285683
docs: cover the remaining Tier 1 audit items (#3325) 2026-08-17 20:18:30 +01:00
Max
2a1cc94493
Gate external PRs on an assigned, linked issue (#3291)
Signed-off-by: Max Isbey <224885523+maxisbey@users.noreply.github.com>
2026-08-17 17:22:04 +01:00
Max
e473cca6a1
Let Client take StdioServerParameters directly (#3321) 2026-08-17 14:31:21 +01:00
Max
fb443cc4b2
MCPServer: content-block returns are unstructured, prompt messages take Image/Audio (#3320) 2026-08-17 14:12:31 +01:00
Max
37b3cb1ef7
Stop framing breaking changes as a workflow in AGENTS.md (#3286) 2026-08-16 18:10:16 +01:00
Max
31b76cbeed
Drop later-revision cache-hint fields on pre-2026 sessions (#3223) 2026-08-16 15:25:22 +01:00
Max
959569ba15
Publish versioning, roadmap, and dependency policies for v2 (#3215) 2026-08-16 15:08:17 +01:00
Max
fc41dd56c8
docs: lead the README client example with a URL, not the server object (#3315) 2026-08-16 14:27:01 +01:00
Max
5285e936a9
Pin text I/O to UTF-8 and fail CI on locale-dependent reads/writes (#3296)
Co-authored-by: ShuQingDollarVoyager <57471784+ShuQingDollarVoyager@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 12:41:53 +01:00
Max
52ad0a8876
docs: publish translated docs in twelve languages and the tool that maintains them (#3280) 2026-08-14 17:07:34 +01:00
Max
14aa889992
Pin each conformance leg to a spec-revision wire (#3304) 2026-08-14 13:39:06 +01:00
ShuQingDollarVoyager
2378e560fb
Read UTF-8 test fixtures with explicit encoding (#3245)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-14 11:08:55 +01:00
Max
6e304527a5
Bump conformance harness to 0.2.0-alpha.11 (#3282) 2026-08-11 15:40:21 +01:00
Max
a4f4ccd091
Link the released 2026-07-28 spec and point migrators at /v1/ (#3214) 2026-07-29 15:01:27 +01:00
Max
fe47969fb9
Ask which release line a bug report is on (#3213) 2026-07-29 14:55:15 +01:00
Max
d82ed88eb5
Describe the maintenance line without hardcoding 1.28 (#3212) 2026-07-29 14:54:45 +01:00
Max
b31ddf37ed
Retire wording tied to pre-2.0 milestones (#3211) 2026-07-29 14:54:23 +01:00
Max
6f69a3758e
Present v2 as the stable release across the README, docs, and policies (#3178)
Some checks failed
CI / checks (push) Has been cancelled
CI / all-green (push) Has been cancelled
2026-07-28 14:31:36 +01:00
Max
78e6fbb7e4
Serve v2 docs at the site root, with permanent per-major paths (#3176) 2026-07-28 13:57:21 +01:00
Max
af06330a31
Remove unused StreamableHTTPTransport.get_session_id() (#3205) 2026-07-28 13:55:02 +01:00
Max
68ca87e20b
Document the two-line release process for stable v2 (#3179) 2026-07-28 13:51:31 +01:00
Max
c9c431b71a
Expose the middleware chain on MCPServer and stop sending unrequested change notifications (#3201) 2026-07-28 12:24:23 +01:00
Max
528e366558
Fail fast on server-to-client requests in JSON-response mode instead of hanging (#3195) 2026-07-28 11:04:51 +01:00
Max
27f5cc7a46
Remove unused mcpserver.exceptions.ValidationError (#3199) 2026-07-28 10:25:51 +01:00
Max
89c5e700f2
Gate log notifications on the per-request log-level opt-in at 2026-07-28 (#3198) 2026-07-28 02:20:33 +01:00
Max
b61ce388dd
docs: fix off-by-one hl_lines in apps.md (#3196) 2026-07-28 00:04:57 +01:00
Max
b7c9a916d6
Add mcp.types as a permanent alias for mcp_types (#3190) 2026-07-27 23:47:04 +01:00
Max
923341c98a
Stop answering cancelled requests (#3188) 2026-07-27 23:26:00 +01:00
Max
e8ef138153
docs: fill migration-guide gaps found by automated v1-to-v2 migration runs (#3187) 2026-07-27 23:17:17 +01:00
Max
d3ffe87960
Split the registration request model from the registered-client record (#3181) 2026-07-27 23:11:01 +01:00
Max
b9422f1c9b
Make the per-version wire packages private (mcp_types._v*) (#3191) 2026-07-27 22:16:48 +01:00
Max
45f2a88a9a
Point pre-release install pins at 2.0.0rc1 (#3186)
Some checks failed
CI / checks (push) Has been cancelled
CI / all-green (push) Has been cancelled
2026-07-27 14:23:57 +01:00
Max
333aca7ac8
Repin conformance harness to the published 0.2.0-alpha.10 (#3184) 2026-07-27 14:03:06 +01:00
Max
dcd9c1ee9f
Lengthen the demo signing keys in the identity-assertion examples (#3180) 2026-07-26 11:45:26 +01:00
Jeremiah Lowin
f599cdfcf9
Cache compiled output-schema validators on ClientSession (#3134)
Co-authored-by: Max Isbey <224885523+maxisbey@users.noreply.github.com>
2026-07-26 11:29:10 +01:00
Max
11934c90ae
Replace FileResource.is_binary with an encoding field (#3171) 2026-07-26 00:58:06 +01:00
Max
814072c94d
Narrow message_handler's parameter to notifications and exceptions (#3168) 2026-07-26 00:24:48 +01:00
Max
47bfa85e83
Remove the unused timeout parameter from OAuthClientProvider (#3165) 2026-07-26 00:22:15 +01:00
Max
3212591946
Stop advertising MCP_* env vars for MCPServer settings; drop pydantic-settings (#3170) 2026-07-25 23:22:27 +01:00
Max
7163d8263f
Remove the deprecated RFC7523OAuthClientProvider (#3169) 2026-07-25 22:50:57 +01:00
Max
1963af52cc
Correct stable v2 target date to 2026-07-28 (#3105) 2026-07-25 20:36:08 +01:00
Max
e90a66b554
Rename scopes= to scope= on the client-credentials OAuth providers (#3166) 2026-07-25 20:23:36 +01:00
Max
5dd062d077
Remove Context.client_id (#3167) 2026-07-25 19:11:51 +01:00
Max
0cb920f126
Make CacheConfig() the Client cache default and None the off switch (#3164) 2026-07-25 17:50:53 +01:00
Max
629ca297d2
Isolate the stdio server's stdin and stdout from handler subprocesses (#3117) 2026-07-25 13:05:51 +01:00
Max
00a70148bc
Serve the 2026-07-28 protocol over stdio: decide the era from the opening request (#3152) 2026-07-24 13:46:37 +01:00
Max
837ef904f8
Align with spec #3002: optional clientInfo, serverInfo in result _meta (#3143) 2026-07-23 12:00:36 +01:00
Max
3a6f2996cd
docs: load media examples from disk instead of inline base64 (#3108) 2026-07-16 20:41:05 +01:00
Max
ebcc4dc3fb
Pin pymdown-extensions back to 11.0 (#3106) 2026-07-16 20:25:33 +01:00
Max
497f7afa61
docs: make API reference rendering independent of page order (#3107) 2026-07-16 18:29:44 +01:00
Andre.Kalberer
e464f72c12
docs: document Windows stdio subprocess stdin handling (#3079) 2026-07-16 11:25:47 +01:00
Marcelo Trylesinski
03aaebd3aa
Add Streamable HTTP request body limits (#3095) 2026-07-16 08:33:32 +02:00
Marcelo Trylesinski
2713b53b12
Replace httpx and httpx-sse with httpx2 (#2972)
Some checks failed
CI / checks (push) Has been cancelled
CI / all-green (push) Has been cancelled
Co-authored-by: Max Isbey <224885523+maxisbey@users.noreply.github.com>
2026-07-14 17:05:08 +01:00
Otis Cui
1216c53693
fix: reject trailing newline in tool-name and URI-template varname validation (#3076)
Python's $ with re.match also matches just before a single trailing newline, so tool-name validation accepted "name\n" and UriTemplate.parse accepted varnames like "foo\n". Switch both checks to re.fullmatch.

Closes #3084
2026-07-10 12:56:54 +00:00
Marcelo Trylesinski
4fc8882c02
docs: replace MkDocs with Zensical (#3073)
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Max Isbey <224885523+maxisbey@users.noreply.github.com>
2026-07-10 12:48:46 +01:00
Max
74a242ae7f
ci: pick the docs-preview toolchain from the PR checkout (#3081) 2026-07-09 13:57:14 +01:00
Max
148278e07f
Gate the test matrix and retry setup-uv's flaky manifest fetch (#3080) 2026-07-09 12:32:53 +01:00
Max
9bdc03d54e
Add the client-side subscriptions/listen driver (#3047) 2026-07-07 14:26:09 +01:00
Max
6d2e908f2b
docs: pin mkdocs<2 and silence the mkdocs-material advisory banner in CI (#3072) 2026-07-07 13:45:30 +01:00
Max
867bba6263
Share one event loop per test module to stop Windows socketpair churn (#3070) 2026-07-07 13:19:04 +01:00
Max
d287c9868f
Extend resolver DI to sampling and roots requests (#3049) 2026-07-06 18:25:57 +01:00
Max
53117cb3a9
Make client-side cancellation work over the 2026 transports (#3046) 2026-07-02 19:21:04 +01:00
Max
bf4402725d
docs: restructure the migration guide around topical groups with a navigation layer (#3058) 2026-07-02 18:54:21 +01:00
Max
2359b40285
docs: modernize the site theme (#3057) 2026-07-02 16:17:33 +01:00
Max
e4d95e0d44
docs: add a "What's new in v2" page (#3054) 2026-07-02 15:01:30 +01:00
Max
220d362112
docs: restructure into topical sections and add the four most-asked-for pages (#3044) 2026-07-01 21:06:04 +01:00
Max
080f2a869d
Harden the dual-era stream loop's era-lock and rejection semantics (#3040) 2026-07-01 17:07:12 +01:00
Max
d39c68df23
De-flake conformance CI: solo re-verification, spawn-storm reduction, result artifacts (#3043) 2026-07-01 16:59:06 +01:00
Max
0da9092037
Point pre-release install pins at 2.0.0b1 (#3039)
Some checks failed
CI / checks (push) Has been cancelled
CI / all-green (push) Has been cancelled
2026-07-01 00:14:52 +01:00
Max
e50fb5be19
Serve the 2026-07-28 era over stdio and other stream-pair transports (#3038) 2026-07-01 00:11:56 +01:00
Max
dcf8a6a0b5
Document pydantic.ValidationError in client Raises sections (#3036) 2026-07-01 00:03:17 +01:00
Max
410cc0db31
Add v2 feedback issue template (#3037) 2026-07-01 00:03:11 +01:00
Max
ca10dade2c
Serve subscriptions/listen with a pluggable event bus (SEP-2575) (#3035) 2026-06-30 23:01:04 +01:00
Max
48ef569f7e
Validate Mcp-Param-* headers server-side on the 2026-07-28 HTTP path (SEP-2243) (#3033) 2026-06-30 21:39:32 +01:00
Max
4df609119f
Add a client extension API (#3034) 2026-06-30 21:31:02 +01:00
Max
7322ca56f4
Require integrity protection for MRTR requestState (#3032) 2026-06-30 21:30:32 +01:00
Den Delimarsky
985652491a
Add Cloudflare Pages docs preview with /preview-docs slash command (#3028) 2026-06-30 16:47:21 +01:00
Max
0b200ef03b
Surface skipped conformance scenarios as baselined known failures (#3030) 2026-06-30 13:07:05 +01:00
Max
b15b1d5f07
Add a client-side response cache honoring SEP-2549 caching hints (#3023) 2026-06-30 11:31:06 +01:00
Max
67d7593df1
docs: publish llms.txt and markdown renditions of the docs (#3024) 2026-06-30 11:30:55 +01:00
Max
8d0f928e40
Pass InputRequiredResult through the MCPServer prompt and resource pipelines (#3020) 2026-06-29 16:50:58 +01:00
Max
8f2c97b769
Consult request_state only for the question a resolver is asking (#3019) 2026-06-29 16:44:05 +01:00
Max
533c6a8226
Add cache_hints constructor map for SEP-2549 caching hints (#3015) 2026-06-29 14:11:15 +00:00
Marcelo Trylesinski
c85836a081
Drive resolver elicitation over the 2026-07-28 input_required flow (#2986)
Co-authored-by: Max Isbey <224885523+maxisbey@users.noreply.github.com>
2026-06-29 14:39:43 +01:00
Max
24fdd909ac
docs: convert bold cross-references into links, link SEP and RFC mentions (#3017) 2026-06-29 11:07:26 +00:00
Max
f2e63c979a
Promote the v2 README to README.md ahead of the first v2 beta (#3014) 2026-06-29 12:01:54 +01:00
Marcelo Trylesinski
f664db8952
Add resolver dependency injection for MCPServer tools (#2969)
Co-authored-by: Max Isbey <224885523+maxisbey@users.noreply.github.com>
2026-06-29 11:51:46 +01:00
Marcelo Trylesinski
4b519782f1
Add a pluggable server extension API with MCP Apps (#3003)
Co-authored-by: Max Isbey <224885523+maxisbey@users.noreply.github.com>
2026-06-29 10:58:05 +01:00
Max
fe9723853d
Wire SEP-990 enterprise-managed-authorization conformance fixture (#3007) 2026-06-29 10:42:37 +01:00
Max
e942d00b98
Re-vendor 2026-07-28 schema at spec ead35b59 (SubscriptionsListenResult) (#3006) 2026-06-27 10:15:34 +02:00
Max
3b78f86886
Add docs, tested examples, and a story for SEP-990 identity assertion (#3004) 2026-06-26 21:01:46 +02:00
Max
24717cc8eb
feat: RFC 6570 URI templates with operator-aware security (#2356) 2026-06-26 20:29:17 +02:00
Max
067f90578c
Add SSE response mode to the 2026 streamable-HTTP server entry (#3001) 2026-06-26 19:09:08 +02:00
Marcelo Trylesinski
c0ecb70e24
Support RFC 8693 token exchange for enterprise IdP flows (SEP-990) (#2988)
Co-authored-by: Max Isbey <224885523+maxisbey@users.noreply.github.com>
2026-06-26 17:57:10 +02:00
Marcelo Trylesinski
ecdf09d44f
Deprecate Server.__init__ handlers for removed capabilities (#3002) 2026-06-26 17:51:13 +02:00
Max
08b62308d4
Client auto-resolves InputRequiredResult via existing callbacks (SEP-2322) (#2998) 2026-06-26 17:35:23 +02:00
Marcelo Trylesinski
3945bdde11
Remove the dispatch-tier middleware hook (#2997) 2026-06-26 17:08:16 +02:00
Max
3caa445c6c
Pin conformance harness by commit SHA (ed314a73) instead of PR number (#3000) 2026-06-26 17:01:42 +02:00
Marcelo Trylesinski
b31d95a429
Make OpenTelemetry tracing the single default middleware (#2995) 2026-06-26 15:47:37 +02:00
Marcelo Trylesinski
cc596195bb
Switch RFC7523OAuthClientProvider warning to MCPDeprecationWarning (#2996) 2026-06-26 15:27:57 +02:00
Marcelo Trylesinski
5b2713d40c
Mirror x-mcp-header tool arguments into Mcp-Param-* request headers (SEP-2243) (#2990) 2026-06-26 14:36:56 +02:00
Max
3a8da8c0c3
Fix docs/release follow-ups from the mcp-types package split (#2977) 2026-06-26 13:16:09 +02:00
Max
1b92efedc8
Add .claude/skills/test-quality and reference it from AGENTS.md (#2993) 2026-06-26 13:05:05 +02:00
Max
411a6d3980
Rebuild the docs around tested examples; shrink README.v2.md to a pitch (#2978) 2026-06-26 12:49:19 +02:00
Max
4caa41f6d5
Add story-style examples suite (27 stories + harness + CI) (#2957) 2026-06-26 12:02:27 +02:00
Max
9dc8c5f02d
find_invalid_x_mcp_header: never repr a non-string annotation value (#2989)
Co-authored-by: Marcelo Trylesinski <marcelotryle@gmail.com>
2026-06-26 09:54:28 +00:00
Marcelo Trylesinski
f41a5193f3
Preserve empty issuer/resource paths on AuthSettings (#2987) 2026-06-26 11:41:41 +02:00
Marcelo Trylesinski
e9cd1692b0
Remove comment-on-release workflow (#2985) 2026-06-26 09:00:51 +00:00
Max
587340279e
Conformance burn-down: server-side InputRequiredResult, Mcp-Method/Name validation, x-mcp-header filter (14 scenarios → green) (#2974)
Some checks failed
CI / checks (push) Has been cancelled
CI / all-green (push) Has been cancelled
2026-06-26 09:51:59 +02:00
Max
603342f0fe
Set Development Status classifier to Production/Stable (#2975) 2026-06-25 23:47:31 +02:00
Marcelo Trylesinski
0ee7f1b293
Split protocol types into a standalone mcp-types package (#2973) 2026-06-25 19:18:38 +02:00
Max
03681ed55e
Client call_tool: input_responses/request_state retry params; InputRequiredResult via allow_input_required (#2968) 2026-06-25 17:37:00 +02:00
Marcelo Trylesinski
96bf22e57a
Stop flagging snake_case is_error results as tool errors in OTel span (#2971) 2026-06-25 15:24:45 +00:00
Marcelo Trylesinski
1b1abf6ab6
Add GenAI semantic-convention attributes to OpenTelemetryMiddleware (#2970) 2026-06-25 14:43:54 +00:00
Max
f226d00d0a
Client-side 2026-07-28 support: .discover()/.adopt() + Client(mode=); request-metadata green (#2950) 2026-06-25 16:09:23 +02:00
Max
ae13ede143
lowlevel Server: widen on_* return types for InputRequiredResult; add subscriptions/listen slot (#2967) 2026-06-25 14:20:01 +02:00
Max
a527142312
Buffer per-request StreamableHTTP streams to avoid serial-router head-of-line block (#2934) 2026-06-22 16:20:45 +01:00
Max
44ce901ce3
OAuth client: keep refresh_token on non-rotating refresh; restore same-origin issuer binding (#2946) 2026-06-22 15:21:52 +01:00
Marcelo Trylesinski
ad81ca234a
Slim ServerMiddleware to (ctx, call_next) and add OpenTelemetryMiddleware (#2941)
Co-authored-by: Max Isbey <224885523+maxisbey@users.noreply.github.com>
2026-06-22 14:46:30 +01:00
Max
5e013d9c54
OAuth client: harden SEP-2352/SEP-2350 edge cases; fix conformance comment (#2936) 2026-06-22 14:45:20 +01:00
Max
2397319a68
Server-side 2026-07-28 stateless support: classifier, driver split, server/discover (#2928) 2026-06-21 19:34:17 +01:00
Marcelo Trylesinski
44724284b3
Bind client credentials to their authorization server (SEP-2352) (#2933) 2026-06-20 18:47:22 +01:00
Marcelo Trylesinski
3169922492
Move scope step-up test to top-level function (#2932) 2026-06-20 18:45:10 +01:00
Marcelo Trylesinski
1331131650
Union previously requested scopes on step-up re-authorization (SEP-2350) (#2931) 2026-06-20 18:45:04 +02:00
Marcelo Trylesinski
4573e4ac33
Deprecate roots, sampling, and logging methods per SEP-2577 (#2926) 2026-06-20 18:25:41 +02:00
Marcelo Trylesinski
5734acf18e
Pass json-schema-ref-no-deref conformance scenario (SEP-2106) (#2924) 2026-06-20 18:23:58 +02:00
Marcelo Trylesinski
cf41441e44
Send application_type during Dynamic Client Registration (SEP-837) (#2930) 2026-06-20 18:19:12 +02:00
Marcelo Trylesinski
60f37e9d7c
Document redirect_uri wire-format change in OAuth migration note (#2929) 2026-06-20 16:14:03 +00:00
Max
f5fe42f5a4
ci(conformance): bump harness to 0.2.0-alpha.5 preview (#2927) 2026-06-20 16:54:50 +01:00
Marcelo Trylesinski
48cf4950dc
Validate the iss authorization-response parameter (RFC 9207 / SEP-2468) (#2921) 2026-06-20 17:54:18 +02:00
Marcelo Trylesinski
b7a5bffed0
Preserve empty URL paths on OAuth metadata models (#2925) 2026-06-20 15:32:03 +00:00
Max
5a3412ddc1
Ignore pre-2026 protocol_version pins at the StreamableHTTP transport (#2923) 2026-06-20 17:29:57 +02:00
冯基魁
fda4c54362
fix: correct MCPServer call_tool result type (#2816)
Co-authored-by: Marcelo Trylesinski <marcelotryle@gmail.com>
2026-06-20 16:56:16 +02:00
Marcelo Trylesinski
f253682393
Return -32602 for resource not found (SEP-2164) (#2920) 2026-06-20 16:55:23 +02:00
Marcelo Trylesinski
0fa03ec69f
Add uv ecosystem to dependabot and drop weekly lockfile workflow (#2919) 2026-06-20 15:13:17 +01:00
Max
84bf9bde05
First end-to-end 2026-07-28 stateless tools/call (experimental entry + ClientSession pin) (#2917) 2026-06-20 14:55:59 +01:00
Max
1cec2d60f4
Relax monolith ElicitRequestURLParams.elicitation_id for 2026-07-28 (#2913) 2026-06-19 16:06:08 +01:00
Max
510832aa45
Re-vendor 2026-07-28 schema and absorb spec #2907 error-code renumber (#2912) 2026-06-19 15:46:15 +01:00
Max
9e6d00318c
ci(conformance): add 2026-07-28 carried-forward leg + bump harness to 0.2.0-alpha.4 (#2911) 2026-06-19 14:48:21 +01:00
Max
364b762ef6
tests/interaction: era-axis machinery for the requirements manifest (#2909) 2026-06-19 12:59:39 +01:00
Max
734746a3d9
Resolve protocol version per request and expose it as ctx.protocol_version (#2886) 2026-06-17 08:46:42 +01:00
Max
c85ccf1c6f
docs: add AI-assisted contribution policy to CONTRIBUTING.md (#2887) 2026-06-16 23:16:07 +01:00
Max
df81c06221
Pass a list to parametrize in test_docs_examples (pytest 9.1.0 compat) (#2890) 2026-06-16 23:15:39 +01:00
1516 changed files with 178938 additions and 15669 deletions

View file

@ -0,0 +1,150 @@
---
name: test-quality
description: Test quality bar for this repo. Read when writing, reviewing, or designing tests — covers naming, abstraction level, assertions, determinism, and the process for agent-driven test work.
---
# Test & code quality guide
What "best practice for new work" means in this repo. Each rule carries its recorded reasoning
where one exists; a rule with no stated why is a convention — follow it anyway.
## Naming & shape
- **Test names are behaviour sentences** stating the observable outcome, not the feature being
poked: `test_elicit_form_decline_returns_no_content`, never `test_elicit_form_decline`.
- Plain top-level `test_*` functions; no `Test` classes (legacy files have them — don't copy).
- **Docstrings: 1–2 sentences of behaviour, honest about provenance** — spec-mandated,
SDK-defined, or pinning a known gap? Say which: provenance is the triage key when the test
later fails. A pinned-gap assertion breaking usually means a change *fixed* the gap; a
spec-mandated assertion breaking means a regression.
- Define things in dependency order; nothing forward-references. For client↔server tests:
handlers → server construction → client setup → act → assert — the test reads in the order
the conversation happens.
- Inline the server (or equivalent setup) in the test, so the whole observable behaviour fits
on one screen. Lift to a file-level fixture only when several tests in *that file* genuinely
share it; never share across files.
- A big multi-step test is fine when the property is irreducibly multi-step (e.g. resumability).
Split when a failure wouldn't tell you which claim broke — not for shortness. Compensate with
a numbered "Steps:" docstring so a reader sees the choreography before the body.
## Level of abstraction
- **Drive through the highest-level public API that can observe the property.** Hand-built wire
requests are brittle and don't prove the user-facing contract; tests that stay above the
internals keep working when the internals change. Drop to raw HTTP only when the assertion is
about something the high-level API *cannot* observe (status codes, headers, wire framing).
- **Scripting a peer over raw streams is a last resort**, reserved for behaviour the typed API
cannot *produce* (malformed input, an impossible peer response). First ask what it would take
for the public API to express it — often a small helper suffices. Every such test's docstring
states why the public API couldn't do it.
- **In-memory / in-process first.** HTTP-, SSE-, and auth-shaped behaviour can all be driven
through an in-process ASGI transport; threads only when necessary, subprocesses only when the
process boundary is itself the thing under test. In-process isn't just faster — it surfaces
bugs (a real stream leak was found this way) that subprocess indirection masks.
- **Tests read like real user code**: no aliasing shims in conftest, no walls of suppressions,
no private imports unless that is genuinely the documented way to do the thing.
- The assertion must prove the round trip — no side-channel state. What the server saw comes
back through the protocol, or via a closure-captured list asserted after the call. Handlers
assert their dispatch identity first (`assert params.name == "add"`), proving the request
that arrived is the request the test sent.
## Assertions
- **Transformations** (input → output the SDK produced) → full-object `snapshot(...)` equality,
so an added or dropped field fails. Regenerate with `--inline-snapshot=fix` so intentional
changes arrive as a reviewable diff; never hand-edit snapshot literals.
- **Pass-through values** (opaque tokens, `_meta`, cursors) → identity against the same
variable you sent. A snapshot of a pass-through value only "matches" because a human checked
two literals correspond — it proves nothing.
- **Errors** → `pytest.raises` + `.code` against the named constant; snapshot SDK-authored
messages; never `match=` on message text. Third-party text (pydantic, jsonschema) → stable
prefix only — never pin text that changes with a dependency upgrade — with a comment saying so.
## Determinism
- **No sleeps, ever.** A sleep guesses at timing instead of waiting on the condition, so it
either flakes or pads the run — sleeps head the list of the older test code's failure modes.
Coordinate with `anyio.Event` so the wait ends exactly when the condition holds (that
discipline is why 529 e2e tests run in ~10 s). Sole exception: tests *of* time-based
features, with a comment.
- Bound every indefinite wait with `anyio.fail_after(5)`. **5 is the standard** — widening it
needs an articulable reason; "10 to be safe" is covering up a flake, not fixing one, and
unexplained widenings propagate (one agent used 10 and every later one copied it).
- **Never assert wall-clock time, even with huge margins.** `elapsed < 0.9` on a ~0.01 s
operation — a 90× margin — was still rejected in review. `fail_after` bounding a hang is the
only timing primitive allowed.
- **Concurrency tests must prove genuine interleaving.** Without barriers the scheduler is free
to serialize — "a" can start, finish, and return before "b" even begins — so two `start_soon`
calls prove nothing. Gate with events so all parties are mid-flight before any proceeds, emit
interleaved, then assert the demux.
- **Don't over-synchronize either.** When delivery ordering is guaranteed (notifications
emitted during a request you're awaiting, over a single ordered in-memory stream), a plain
collected list asserted after the call is correct; events are for messages not tied to an
awaited operation. Verify the ordering guarantee actually holds for your transport first.
- Async tests use anyio, not asyncio.
## Behaviour philosophy
- **Pin current behaviour; never xfail.** A green suite asserting what actually happens is a
regression bar for any refactor; an xfail proves nothing about it. Where current behaviour
falls short of spec, pin the divergent output and record the gap as data — a tracking issue,
with the docstring naming the known gap (suites with a requirements manifest record it as a
divergence entry). Not hidden, not skipped.
- **Hollow-proof check**: before claiming a test covers a behaviour, re-read the claim and ask
"which assertion proves *this*?" A passing test near a behaviour is not proof of it — a full
review of the e2e suite found two such cases even under this discipline.
## Hygiene
- No new `# pragma`, `# type: ignore`, `# noqa` by default — restructure first; a suppression
usually means a test or a type is missing. The narrow sanctioned escape hatches (and the
audit to run before pushing) are in AGENTS.md. In tests, narrow types with `assert
isinstance`; never `Any`/`object` when a real type exists.
- **Warnings raised during tests are findings, not noise** (the repo runs
`filterwarnings = error`). Fix the cause; if the fix can't land in the same change, scope the
suppression to the one fixture that needs it and track the real fix explicitly.
- Registered-but-never-invoked handler bodies are `raise NotImplementedError`, so they cannot
silently become load-bearing.
- Comments live next to the line they explain, not in docstrings; single backticks for code
refs; match the surrounding comment density (one-liners next to one-liners). No
`from __future__ import annotations` (py310+ repo).
- Test work doesn't change `src/` as a side effect — the one mechanical exception is deleting a
pragma a new test now covers. If a test can't be written without a library change, raise it
as a finding or defer the test; don't quietly edit `src/`.
## Process (for agent-driven work)
- **Small chunks.** ≤10 tests per human-reviewed batch; when fanning out to multiple agents,
≤5 per agent. Review quality scales inversely with batch size: the observed shortcuts
(over-stacked tests, a timing assertion, wrong abstraction level) all surfaced in one
oversized 27-test batch.
- **Design → review → implement, as separate steps.** The design deliverable is not just the
plan — it's the judgement calls (abstraction level, deferrals, contested assertions) stated
explicitly, so the reviewer vetoes them before implementation rather than discovering them
in the diff.
- **High-stakes areas get fresh adversarial reviewers** on both the design and the
implementation — fresh, because whoever designed it (or saw the proposed fix) anchors on the
same layer. SERIOUS findings (wrong assertion, missed MUST/SHOULD, an unrecorded known gap)
re-loop; style doesn't. Reserve the full panel for areas where being wrong is worse than
being slow.
- **Investigations pair a full-context look with a fresh agent given only the problem** —
never the proposed fix — and compare conclusions. The unbiased read regularly catches
anchoring on the wrong layer.
- **When review questions a decision, reconsider genuinely**: re-derive the tradeoff, state the
options, recommend with reasons. Defending the original choice is a valid outcome;
reflexively agreeing with every challenge is as bad as ignoring it.
- **Validate the reference artifact before building on it.** Whatever you treat as ground truth
(a spec import, a baseline, a generated list), check it *first* — discovering it was
incomplete after five batches costs far more than before batch one.
- **Verify as you go**: per-file `uv run --frozen pytest <file> -q` + pyright + ruff while
iterating; full suite, coverage, and `./scripts/test` (when `src/` was touched) at
integration.
- **Every agent writes notes**: what it did, what broke, and — most importantly — what it
couldn't decide, so open questions surface instead of being silently resolved by whoever hit
them.
- **Quality over speed is a stated goal, not a preference** ("rushing something out the door is
not the goal here, explicitly so"). Don't quietly de-scope agreed work mid-stream;
renegotiate scope explicitly.
- **Don't copy patterns from existing test code by default** — much of the repo's older test
code is below the current bar (sleeps, mocks, `Test` classes, raciness). These rules define
the bar for new work.

View file

@ -11,13 +11,26 @@ body:
id: checks
attributes:
label: Initial Checks
description: Just making sure you're using the latest version of MCP Python SDK.
description: >
Both the 2.x stable line and the 1.x maintenance line are supported, and only
the newest release of each line receives fixes.
options:
- label: I confirm that I'm using the latest version of MCP Python SDK
- label: I confirm that I'm using the newest release of my line (the latest 2.x, or the latest 1.x if I'm still on v1)
required: true
- label: I confirm that I searched for my issue in https://github.com/modelcontextprotocol/python-sdk/issues before opening this issue
required: true
- type: dropdown
id: release-line
attributes:
label: Release line
description: Which major version of the SDK are you using?
options:
- 2.x (current stable)
- 1.x (maintenance line, v1.x branch)
validations:
required: true
- type: textarea
id: description
attributes:

59
.github/ISSUE_TEMPLATE/v2-feedback.yaml vendored Normal file
View file

@ -0,0 +1,59 @@
name: v2 feedback
description: Bugs, API friction, or docs gaps in v2 of the SDK
title: "[v2] "
labels: ["v2"]
body:
- type: markdown
attributes:
value: |
Anything in v2 that broke, surprised you, or slowed you down is useful, including API friction and docs gaps.
Docs: https://py.sdk.modelcontextprotocol.io/ · Migration from v1: https://py.sdk.modelcontextprotocol.io/migration/
- type: textarea
id: what
attributes:
label: What happened?
description: What did you do, and what went wrong (or felt wrong)? Paste error output verbatim if there is any.
validations:
required: true
- type: textarea
id: expected
attributes:
label: What did you expect?
validations:
required: false
- type: textarea
id: repro
attributes:
label: Code to reproduce
description: The smallest snippet or repository that shows it. For docs feedback, link the page instead.
render: Python
validations:
required: false
- type: input
id: version
attributes:
label: SDK version
description: The published version (`pip show mcp`) or commit.
validations:
required: false
- type: dropdown
id: area
attributes:
label: Area
options:
- Server
- Client
- Transports
- Auth
- Documentation
- Migration
- Other
validations:
required: false

View file

@ -6,16 +6,28 @@ It handles all conformance test scenarios via environment variables and CLI argu
Contract:
- MCP_CONFORMANCE_SCENARIO env var -> scenario name
- MCP_CONFORMANCE_CONTEXT env var -> optional JSON (for client-credentials scenarios)
- MCP_CONFORMANCE_PROTOCOL_VERSION env var -> spec version the harness mock
server is speaking (e.g. "2025-11-25", "2026-07-28"). Always set; when
--spec-version is omitted the harness picks per-scenario (LATEST_SPEC_VERSION
for active scenarios, DRAFT_PROTOCOL_VERSION for draft-only ones).
- Server URL as last CLI argument (sys.argv[1])
- Must exit 0 within 30 seconds
- Must exit 0 within the harness --timeout (CI passes 60s; the default is 30s)
Scenarios:
initialize - Connect, initialize, list tools, close
tools_call - Connect, call add_numbers(a=5, b=3), close
sse-retry - Connect, call test_reconnection, close
json-schema-ref-no-deref - Connect, list tools (no $ref deref)
json-schema-2020-12-preservation - List tools, echo the focal inputSchema back verbatim
request-metadata - Connect with all callbacks; client stamps _meta
http-standard-headers - Tool, resource and prompt round-trips (Mcp-* headers checked)
http-invalid-tool-headers - List tools, call every surfaced tool (x-mcp-header filter)
http-custom-headers - Replay the harness's toolCalls (x-mcp-header -> Mcp-Param-*)
elicitation-sep1034-client-defaults - Elicitation with default accept callback
sep-2322-client-request-state - Drive the MRTR auto-loop (SEP-2322)
auth/client-credentials-jwt - Client credentials with private_key_jwt
auth/client-credentials-basic - Client credentials with client_secret_basic
auth/enterprise-managed-authorization - SEP-990 ID-JAG (RFC 8693 + RFC 7523 jwt-bearer)
auth/* - Authorization code flow (default for auth scenarios)
"""
@ -28,19 +40,23 @@ from collections.abc import Callable, Coroutine
from typing import Any, cast
from urllib.parse import parse_qs, urlparse
import httpx
import httpx2
import mcp_types as types
from mcp_types.version import MODERN_PROTOCOL_VERSIONS
from pydantic import AnyUrl
from mcp import ClientSession, types
from mcp.client.auth import OAuthClientProvider, TokenStorage
from mcp.client.auth.extensions.client_credentials import (
ClientCredentialsOAuthProvider,
PrivateKeyJWTOAuthProvider,
SignedJWTParameters,
)
from mcp.client.auth.extensions.identity_assertion import IdentityAssertionOAuthProvider
from mcp.client.auth.utils import build_protected_resource_metadata_discovery_urls
from mcp.client.client import Client
from mcp.client.context import ClientRequestContext
from mcp.client.streamable_http import streamable_http_client
from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata, OAuthToken
from mcp.shared.auth import AuthorizationCodeResult, OAuthClientInformationFull, OAuthClientMetadata, OAuthToken
# Set up logging to stderr (stdout is for conformance test output)
logging.basicConfig(
@ -50,6 +66,28 @@ logging.basicConfig(
)
logger = logging.getLogger(__name__)
#: Spec version the harness is running this scenario at (e.g. "2025-11-25",
#: "2026-07-28"). The harness always sets this (when --spec-version is omitted
#: it picks per-scenario: LATEST_SPEC_VERSION for active scenarios,
#: DRAFT_PROTOCOL_VERSION for draft-only ones), so None means we were invoked
#: outside the harness.
PROTOCOL_VERSION: str | None = os.environ.get("MCP_CONFORMANCE_PROTOCOL_VERSION")
def client_mode() -> str:
"""Pick the Client(mode=) for the harness leg.
On a modern leg (2026-07-28+) -> 'auto' so Client.discover() runs and the
_meta envelope + MCP-Protocol-Version header are stamped on every request.
On a handshake-era leg -> 'legacy' so the initialize handshake runs exactly
as before (no server/discover probe is sent against a mock that would 400 it).
Outside the harness -> 'auto' (probe + fallback).
"""
if PROTOCOL_VERSION is None or PROTOCOL_VERSION in MODERN_PROTOCOL_VERSIONS:
return "auto"
return "legacy"
# Type for async scenario handler functions
ScenarioHandler = Callable[[str], Coroutine[Any, None, None]]
@ -109,12 +147,13 @@ class ConformanceOAuthCallbackHandler:
def __init__(self) -> None:
self._auth_code: str | None = None
self._state: str | None = None
self._iss: str | None = None
async def handle_redirect(self, authorization_url: str) -> None:
"""Fetch the authorization URL and extract the auth code from the redirect."""
logger.debug(f"Fetching authorization URL: {authorization_url}")
async with httpx.AsyncClient() as client:
async with httpx2.AsyncClient() as client:
response = await client.get(
authorization_url,
follow_redirects=False,
@ -130,6 +169,8 @@ class ConformanceOAuthCallbackHandler:
self._auth_code = query_params["code"][0]
state_values = query_params.get("state")
self._state = state_values[0] if state_values else None
iss_values = query_params.get("iss")
self._iss = iss_values[0] if iss_values else None
logger.debug(f"Got auth code from redirect: {self._auth_code[:10]}...")
return
else:
@ -139,51 +180,33 @@ class ConformanceOAuthCallbackHandler:
else:
raise RuntimeError(f"Expected redirect response, got {response.status_code} from {authorization_url}")
async def handle_callback(self) -> tuple[str, str | None]:
"""Return the captured auth code and state."""
async def handle_callback(self) -> AuthorizationCodeResult:
"""Return the captured auth code, state, and iss."""
if self._auth_code is None:
raise RuntimeError("No authorization code available - was handle_redirect called?")
auth_code = self._auth_code
state = self._state
result = AuthorizationCodeResult(code=self._auth_code, state=self._state, iss=self._iss)
self._auth_code = None
self._state = None
return auth_code, state
self._iss = None
return result
# --- Scenario Handlers ---
# --- Stub callbacks (declare capabilities in _meta without doing real work) ---
@register("initialize")
async def run_initialize(server_url: str) -> None:
"""Connect, initialize, list tools, close."""
async with streamable_http_client(url=server_url) as (read_stream, write_stream):
async with ClientSession(read_stream, write_stream) as session:
await session.initialize()
logger.debug("Initialized successfully")
await session.list_tools()
logger.debug("Listed tools successfully")
async def stub_sampling_callback(
context: ClientRequestContext,
params: types.CreateMessageRequestParams,
) -> types.CreateMessageResult | types.ErrorData:
return types.CreateMessageResult(
role="assistant",
content=types.TextContent(type="text", text=""),
model="conformance-stub",
)
@register("tools_call")
async def run_tools_call(server_url: str) -> None:
"""Connect, initialize, list tools, call add_numbers(a=5, b=3), close."""
async with streamable_http_client(url=server_url) as (read_stream, write_stream):
async with ClientSession(read_stream, write_stream) as session:
await session.initialize()
await session.list_tools()
result = await session.call_tool("add_numbers", {"a": 5, "b": 3})
logger.debug(f"add_numbers result: {result}")
@register("sse-retry")
async def run_sse_retry(server_url: str) -> None:
"""Connect, initialize, list tools, call test_reconnection, close."""
async with streamable_http_client(url=server_url) as (read_stream, write_stream):
async with ClientSession(read_stream, write_stream) as session:
await session.initialize()
await session.list_tools()
result = await session.call_tool("test_reconnection", {})
logger.debug(f"test_reconnection result: {result}")
async def stub_list_roots_callback(context: ClientRequestContext) -> types.ListRootsResult | types.ErrorData:
return types.ListRootsResult(roots=[])
async def default_elicitation_callback(
@ -206,17 +229,209 @@ async def default_elicitation_callback(
return types.ElicitResult(action="accept", content=content)
# --- Scenario Handlers ---
@register("initialize")
async def run_initialize(server_url: str) -> None:
"""Connect, initialize, list tools, close."""
async with Client(server_url, mode=client_mode()) as client:
logger.debug("Initialized successfully")
await client.list_tools()
logger.debug("Listed tools successfully")
@register("json-schema-ref-no-deref")
async def run_json_schema_ref_no_deref(server_url: str) -> None:
"""Initialize and list tools; the scenario fails only if the client fetches a network $ref.
The client never walks inputSchema or resolves $refs, so listing is enough (SEP-2106).
Pinned to mode='legacy': the harness reports PROTOCOL_VERSION=2026-07-28 for this
scenario but its mock server only speaks the handshake-era lifecycle and 400s a
modern-stamped tools/list. The check is lifecycle-agnostic so this is harmless.
"""
async with Client(server_url, mode="legacy") as client:
await client.list_tools()
@register("json-schema-2020-12-preservation")
async def run_json_schema_2020_12_preservation(server_url: str) -> None:
"""List tools, then echo the focal tool's inputSchema back verbatim (SEP-1613 / SEP-2106).
The harness diffs what the client round-trips through `json_schema_echo` against its
fixture to detect 2020-12 keywords ($schema, $defs, $anchor, additionalProperties,
allOf/anyOf, if/then/else) being stripped while parsing tools/list. Unlike
json-schema-ref-no-deref, this mock is version-aware, so client_mode() applies.
"""
async with Client(server_url, mode=client_mode()) as client:
listed = await client.list_tools()
focal = next(tool for tool in listed.tools if tool.name == "json_schema_2020_12_tool")
await client.call_tool("json_schema_echo", {"schema": focal.input_schema})
@register("tools_call")
async def run_tools_call(server_url: str) -> None:
"""Connect, list tools, call add_numbers(a=5, b=3), close."""
async with Client(server_url, mode=client_mode()) as client:
await client.list_tools()
result = await client.call_tool("add_numbers", {"a": 5, "b": 3})
logger.debug(f"add_numbers result: {result}")
@register("sse-retry")
async def run_sse_retry(server_url: str) -> None:
"""Connect, list tools, call test_reconnection, close."""
async with Client(server_url, mode=client_mode()) as client:
await client.list_tools()
result = await client.call_tool("test_reconnection", {})
logger.debug(f"test_reconnection result: {result}")
@register("request-metadata")
async def run_request_metadata(server_url: str) -> None:
"""Connect on the modern path with every client capability declared.
The scenario inspects every request's `_meta` envelope (SEP-2575) for
protocolVersion / clientInfo / clientCapabilities, and the matching
MCP-Protocol-Version header. mode='auto' makes the SDK send
server/discover (covering the unsupported-version retry check), then adopt
and stamp the envelope on the follow-up requests.
"""
async with Client(
server_url,
mode=client_mode(),
sampling_callback=stub_sampling_callback,
list_roots_callback=stub_list_roots_callback,
elicitation_callback=default_elicitation_callback,
) as client:
await client.list_tools()
result = await client.call_tool("add_numbers", {"a": 5, "b": 3})
logger.debug(f"add_numbers result: {result}")
@register("http-standard-headers")
async def run_http_standard_headers(server_url: str) -> None:
"""Touch tools, resources and prompts on the modern path so each standard header is checked (SEP-2243).
The scenario inspects Mcp-Method on each request and Mcp-Name on tools/call,
resources/read and prompts/get, and reports methods the client never sent as
SKIPPED rather than failed, so exercise one of each. initialize and
notifications/initialized stay SKIPPED: the modern path discovers via
server/discover and never sends them.
"""
async with Client(server_url, mode=client_mode()) as client:
tools = await client.list_tools()
if tools.tools:
await client.call_tool(tools.tools[0].name, _stub_required_args(tools.tools[0].input_schema))
resources = await client.list_resources()
if resources.resources:
await client.read_resource(resources.resources[0].uri)
prompts = await client.list_prompts()
if prompts.prompts:
await client.get_prompt(prompts.prompts[0].name)
def _stub_required_args(input_schema: dict[str, Any]) -> dict[str, Any]:
"""Minimal arguments satisfying a tool inputSchema's required list."""
by_type: dict[str, Any] = {
"string": "x",
"integer": 0,
"number": 0,
"boolean": False,
"object": {},
"array": [],
"null": None,
}
properties = input_schema.get("properties", {})
return {name: by_type.get(properties.get(name, {}).get("type"), "x") for name in input_schema.get("required", [])}
@register("http-invalid-tool-headers")
async def run_http_invalid_tool_headers(server_url: str) -> None:
"""List tools, then call every tool the SDK surfaces (SEP-2243).
The harness mock advertises one valid tool plus several with malformed
x-mcp-header annotations (empty, non-primitive type, duplicate, invalid
chars). The scenario passes if valid_tool is called and the malformed
ones are not -- so a conforming client filters them out of the list_tools
result and the loop below never sees them. The scenario sets
allowClientError, so a per-call failure is logged and skipped rather
than aborting the whole run.
"""
async with Client(server_url, mode=client_mode()) as client:
listed = await client.list_tools()
logger.debug(f"Surfaced tools: {[t.name for t in listed.tools]}")
for tool in listed.tools:
try:
await client.call_tool(tool.name, _stub_required_args(tool.input_schema))
except Exception:
logger.exception(f"call_tool({tool.name!r}) failed")
@register("http-custom-headers")
async def run_http_custom_headers(server_url: str) -> None:
"""List tools, then replay the harness's `toolCalls` so x-mcp-header args mirror into headers (SEP-2243).
The scenario supplies the exact arguments to send (including the null/edge-case values that
exercise omission and Base64 encoding) via the context `toolCalls`; using them verbatim is
what drives every per-parameter check. `list_tools` first so the SDK caches each tool's
annotations; a tool the SDK dropped (invalid annotations) is skipped. Per-call failures are
logged and skipped rather than aborting the run.
"""
tool_calls: list[dict[str, Any]] = []
if os.environ.get("MCP_CONFORMANCE_CONTEXT"):
tool_calls = get_conformance_context().get("toolCalls", [])
async with Client(server_url, mode=client_mode()) as client:
listed = await client.list_tools()
surfaced = {tool.name for tool in listed.tools}
logger.debug(f"Surfaced tools: {sorted(surfaced)}")
for call in tool_calls:
name = call["name"]
if name not in surfaced:
logger.debug(f"skipping {name!r}: not surfaced by list_tools")
continue
try:
await client.call_tool(name, call.get("arguments") or {})
except Exception:
logger.exception(f"call_tool({name!r}) failed")
@register("elicitation-sep1034-client-defaults")
async def run_elicitation_defaults(server_url: str) -> None:
"""Connect with elicitation callback that applies schema defaults."""
async with streamable_http_client(url=server_url) as (read_stream, write_stream):
async with ClientSession(
read_stream, write_stream, elicitation_callback=default_elicitation_callback
) as session:
await session.initialize()
await session.list_tools()
result = await session.call_tool("test_client_elicitation_defaults", {})
logger.debug(f"test_client_elicitation_defaults result: {result}")
async with Client(server_url, mode=client_mode(), elicitation_callback=default_elicitation_callback) as client:
await client.list_tools()
result = await client.call_tool("test_client_elicitation_defaults", {})
logger.debug(f"test_client_elicitation_defaults result: {result}")
@register("sep-2322-client-request-state")
async def run_mrtr_client(server_url: str) -> None:
"""Drive the SEP-2322 client mock through `Client.call_tool`'s auto-loop.
The mock inspects raw `tools/call` params, so registering an
`elicitation_callback` and letting the driver run is enough to satisfy
all five wire-shape checks: the driver echoes `request_state` byte-exact
and omits it when the server sent none, every retry mints a fresh
JSON-RPC id, the unrelated call between auto-loops carries no MRTR
params, and the no-`resultType` response parses as a terminal
`CallToolResult` so the driver never retries it.
"""
async def confirm(
context: ClientRequestContext, params: types.ElicitRequestParams
) -> types.ElicitResult | types.ErrorData:
return types.ElicitResult(action="accept", content={"confirmed": True})
async with Client(server_url, mode=client_mode(), elicitation_callback=confirm) as client:
await client.list_tools()
await client.call_tool("test_mrtr_echo_state", {})
await client.call_tool("test_mrtr_unrelated", {})
await client.call_tool("test_mrtr_no_state", {})
result = await client.call_tool("test_mrtr_no_result_type", {})
assert isinstance(result, types.CallToolResult)
@register("auth/client-credentials-jwt")
@ -272,6 +487,70 @@ async def run_client_credentials_basic(server_url: str) -> None:
await _run_auth_session(server_url, oauth_auth)
@register("auth/enterprise-managed-authorization")
async def run_enterprise_managed_authorization(server_url: str) -> None:
"""SEP-990 enterprise-managed authorization: RFC 8693 token-exchange at the
enterprise IdP for an ID-JAG, then RFC 7523 jwt-bearer at the MCP
authorization server."""
context = get_conformance_context()
client_id = context.get("client_id")
client_secret = context.get("client_secret")
idp_client_id = context.get("idp_client_id")
idp_id_token = context.get("idp_id_token")
idp_token_endpoint = context.get("idp_token_endpoint")
if not client_id:
raise RuntimeError("MCP_CONFORMANCE_CONTEXT missing 'client_id'")
if not client_secret:
raise RuntimeError("MCP_CONFORMANCE_CONTEXT missing 'client_secret'")
if not idp_client_id:
raise RuntimeError("MCP_CONFORMANCE_CONTEXT missing 'idp_client_id'")
if not idp_id_token:
raise RuntimeError("MCP_CONFORMANCE_CONTEXT missing 'idp_id_token'")
if not idp_token_endpoint:
raise RuntimeError("MCP_CONFORMANCE_CONTEXT missing 'idp_token_endpoint'")
# IdentityAssertionOAuthProvider takes the AS issuer as configuration (the
# SEP-990 trust model: the resource server is never asked which AS to use).
# The harness does not put the issuer in context, so for conformance we
# learn it from the harness's PRM document (RFC 9728); production
# deployments would supply it as static configuration instead.
prm_url = build_protected_resource_metadata_discovery_urls(None, server_url)[0]
async with httpx2.AsyncClient(timeout=30.0) as http:
prm = (await http.get(prm_url)).raise_for_status().json()
as_issuer = prm["authorization_servers"][0]
async def fetch_id_jag(audience: str, resource: str) -> str:
"""Leg 1 - RFC 8693 token-exchange at the enterprise IdP."""
async with httpx2.AsyncClient(timeout=30.0) as http:
resp = await http.post(
idp_token_endpoint,
data={
"grant_type": "urn:ietf:params:oauth:grant-type:token-exchange",
"requested_token_type": "urn:ietf:params:oauth:token-type:id-jag",
"subject_token": idp_id_token,
"subject_token_type": "urn:ietf:params:oauth:token-type:id_token",
"audience": audience,
"resource": resource,
"client_id": idp_client_id,
},
)
resp.raise_for_status()
return resp.json()["access_token"]
oauth_auth = IdentityAssertionOAuthProvider(
server_url=server_url,
storage=InMemoryTokenStorage(),
client_id=client_id,
client_secret=client_secret,
issuer=as_issuer,
assertion_provider=fetch_id_jag,
token_endpoint_auth_method="client_secret_basic",
)
await _run_auth_session(server_url, oauth_auth)
async def run_auth_code_client(server_url: str) -> None:
"""Authorization code flow (default for auth/* scenarios)."""
callback_handler = ConformanceOAuthCallbackHandler()
@ -314,27 +593,24 @@ async def run_auth_code_client(server_url: str) -> None:
await _run_auth_session(server_url, oauth_auth)
async def _run_auth_session(server_url: str, oauth_auth: OAuthClientProvider) -> None:
async def _run_auth_session(server_url: str, oauth_auth: httpx2.Auth) -> None:
"""Common session logic for all OAuth flows."""
client = httpx.AsyncClient(auth=oauth_auth, timeout=30.0)
async with streamable_http_client(url=server_url, http_client=client) as (read_stream, write_stream):
async with ClientSession(
read_stream, write_stream, elicitation_callback=default_elicitation_callback
) as session:
await session.initialize()
logger.debug("Initialized successfully")
http_client = httpx2.AsyncClient(auth=oauth_auth, timeout=30.0)
transport = streamable_http_client(url=server_url, http_client=http_client)
async with Client(transport, mode=client_mode(), elicitation_callback=default_elicitation_callback) as client:
logger.debug("Initialized successfully")
tools_result = await session.list_tools()
logger.debug(f"Listed tools: {[t.name for t in tools_result.tools]}")
tools_result = await client.list_tools()
logger.debug(f"Listed tools: {[t.name for t in tools_result.tools]}")
# Call the first available tool (different tests have different tools)
if tools_result.tools:
tool_name = tools_result.tools[0].name
try:
result = await session.call_tool(tool_name, {})
logger.debug(f"Called {tool_name}, result: {result}")
except Exception as e:
logger.debug(f"Tool call result/error: {e}")
# Call the first available tool (different tests have different tools)
if tools_result.tools:
tool_name = tools_result.tools[0].name
try:
result = await client.call_tool(tool_name, {})
logger.debug(f"Called {tool_name}, result: {result}")
except Exception as e:
logger.debug(f"Tool call result/error: {e}")
logger.debug("Connection closed successfully")
@ -347,6 +623,7 @@ def main() -> None:
server_url = sys.argv[1]
scenario = os.environ.get("MCP_CONFORMANCE_SCENARIO")
logger.debug(f"Conformance protocol version: {PROTOCOL_VERSION!r} -> mode={client_mode()!r}")
if scenario:
logger.debug(f"Running explicit scenario '{scenario}' against {server_url}")

View file

@ -0,0 +1,26 @@
# Expected failures at the 2025-11-25 wire, read by the `--suite all
# --spec-version 2025-11-25` server and client legs: every scenario the pinned
# harness marks applicable at 2025-11-25, run over the stateful handshake.
#
# That selection is a superset of the frozen 2025-11-25 requirement set (`npx
# $CONFORMANCE_PKG list --requirements 2025-11-25`), so an entry here for a
# scenario in that set means `conformance tier-check` scores the SDK below 100%
# for 2025-11-25 even though CI is green; link the tracking issue next to any
# such entry.
#
# Kept separate from expected-failures.yml even though the bare `--suite all`
# legs currently run these same scenarios at 2025-11-25 too: the harness's
# default wire is due to move (modelcontextprotocol/conformance#426) and this
# file must keep meaning 2025-11-25. Until then a failure here also needs an
# entry there. The file is passed even while empty: run-client.sh keys its solo
# re-verification off the harness's baseline output.
#
# Baseline established against the harness pinned via CONFORMANCE_PKG in
# .github/workflows/conformance.yml. New conformance releases are adopted by
# deliberately bumping that pin and reconciling all three expected-failures
# files in the same change. Entries MUST be removed as they start passing (the
# runner fails on stale entries).
client: []
server: []

View file

@ -0,0 +1,29 @@
# Expected failures at the 2026-07-28 wire, read by the `--suite all
# --spec-version 2026-07-28` server and client legs: every scenario the pinned
# harness marks applicable at 2026-07-28, run stateless with per-request _meta.
#
# That selection is a superset of the frozen 2026-07-28 requirement set (`npx
# $CONFORMANCE_PKG list --requirements 2026-07-28`), so an entry here for a
# scenario in that set means `conformance tier-check` scores the SDK below 100%
# for 2026-07-28 even though CI is green; link the tracking issue next to any
# such entry.
#
# This baseline is separate from expected-failures.yml because entries are
# keyed by scenario name only: a scenario that passes at the harness default
# wire in the bare `--suite all` legs but fails when forced to 2026-07-28 (or
# vice versa) cannot be expressed in a shared file. Where the bare legs already
# default a scenario to 2026-07-28, a failure here also needs an entry there.
#
# Baseline established against the harness pinned via CONFORMANCE_PKG in
# .github/workflows/conformance.yml. New conformance releases are adopted by
# deliberately bumping that pin and reconciling all three expected-failures
# files in the same change.
#
# Entries are grouped by what unblocks them. As each gap closes the
# corresponding scenarios start passing and MUST be removed from this list
# (the runner fails on stale entries), so the baseline burns down per
# milestone.
client: []
server: []

View file

@ -1,86 +1,55 @@
# Conformance scenarios not yet passing against the Python SDK on main.
# CI exits 0 if only these fail, exits 1 on unexpected failures or stale entries.
# Conformance scenarios not yet passing against the Python SDK on main, read by
# the bare `--suite all` server and client legs (every scenario shipped with the
# pinned harness, each at the harness's default wire for it). CI exits 0 if only
# these fail, exits 1 on unexpected failures or stale entries.
#
# Baseline established against @modelcontextprotocol/conformance pinned in
# .github/workflows/conformance.yml (CONFORMANCE_VERSION = 0.2.0-alpha.3).
# New conformance releases are adopted by deliberately bumping that pin and
# reconciling this file in the same change.
# The wire-pinned legs read expected-failures.2025-11-25.yml and
# expected-failures.2026-07-28.yml instead; see the header of
# .github/workflows/conformance.yml for the leg layout.
#
# Baseline established against the harness pinned via CONFORMANCE_PKG in
# .github/workflows/conformance.yml. New conformance releases are adopted by
# deliberately bumping that pin and reconciling all three expected-failures
# files in the same change.
#
# Entries are grouped by SEP. As each SEP lands in the SDK the corresponding
# scenarios start passing and MUST be removed from this list (the runner fails
# on stale entries), so the baseline burns down per milestone.
client:
# --- Draft-spec scenarios (in `--suite draft`, also part of `--suite all`) ---
# SEP-2575 (request metadata / _meta envelope): client does not populate the
# _meta envelope or the MCP-Protocol-Version header semantics yet.
- request-metadata
# SEP-2322 (multi-round-trip requests): client does not echo requestState /
# handle IncompleteResult yet.
- sep-2322-client-request-state
# SEP-2243 (HTTP standardization): no fixture handler / client header support yet.
- http-custom-headers
- http-invalid-tool-headers
# SEP-2106 (JSON Schema $ref handling): client still dereferences network $refs.
- json-schema-ref-no-deref
# SEP-2468 (authorization response iss parameter): not implemented in the client.
- auth/iss-supported
- auth/iss-not-advertised
- auth/iss-supported-missing
- auth/iss-wrong-issuer
- auth/iss-unexpected
- auth/iss-normalized
- auth/metadata-issuer-mismatch
# SEP-2352 (authorization server migration): client does not re-register when
# PRM authorization_servers changes.
- auth/authorization-server-migration
# SEP-837 (application_type during DCR): the check only fires on draft-version
# runs; this draft scenario is the one place the client still hits it.
- auth/offline-access-not-supported
# --- Pre-existing scenarios that fail on checks added after conformance 0.1.15 ---
# SEP-2350 (scope step-up): WARNING-only; the expected-failures evaluator
# counts WARNINGs as failures.
- auth/scope-step-up
# SEP-990 (enterprise-managed authorization extension): no fixture handler /
# client support for the token-exchange + JWT bearer flow.
- auth/enterprise-managed-authorization
# SEP-1932 (DPoP): the SDK's OAuth client does not implement DPoP proofs.
# The entries are per-check (conformance #406) because both scenarios
# pass their non-DPoP checks (discovery, token acquisition, request
# flow) live.
- auth/dpop:sep-1932-client-token-request-proof
- auth/dpop:sep-1932-client-dpop-auth-scheme
- auth/dpop:sep-1932-client-fresh-proof
- auth/dpop-nonce:sep-1932-client-token-request-proof
- auth/dpop-nonce:sep-1932-client-dpop-auth-scheme
- auth/dpop-nonce:sep-1932-client-fresh-proof
- auth/dpop-nonce:sep-1932-client-as-nonce
- auth/dpop-nonce:sep-1932-client-rs-nonce
# Workload identity federation: the OAuth client does not implement the
# urn:ietf:params:oauth:grant-type:jwt-bearer grant (it answers with
# authorization_code). Per-check for the same reason.
- auth/wif-jwt-bearer:wif-grant-type
server:
# --- Draft-spec scenarios (in `--suite draft`; the `active` suite is green) ---
# SEP-2575 (stateless HTTP / _meta envelope): server has no stateless mode,
# _meta-derived capabilities, error-code mappings, or server/discover yet.
- server-stateless
# SEP-2322 (multi-round-trip requests / IncompleteResult): not implemented;
# most scenarios currently fail early with "Missing session ID" because
# mcp-everything-server only runs in stateful mode.
- input-required-result-basic-elicitation
- input-required-result-basic-sampling
- input-required-result-basic-list-roots
- input-required-result-request-state
- input-required-result-multiple-input-requests
- input-required-result-multi-round
- input-required-result-non-tool-request
- input-required-result-result-type
- input-required-result-tampered-state
- input-required-result-capability-check
# SEP-2549 (caching): no ttlMs/cacheScope support; scenario also hits the
# stateful-mode "Missing session ID" error.
- caching
# SEP-2243 (HTTP header standardization): -32001 HeaderMismatch handling and
# case-insensitive/whitespace-trimmed header validation not implemented.
- http-header-validation
- http-custom-header-server-validation
# WARNING-only entries: these scenarios emit no FAILURE checks, only SHOULD-level
# WARNINGs, but the expected-failures evaluator counts WARNINGs as failures.
# SEP-2164: server returns -32600 (not -32602) and omits error.data.uri.
- sep-2164-resource-not-found
# SEP-2322 SHOULD-level behaviours (re-request missing inputResponses, ignore
# unrecognized inputResponses keys).
- input-required-result-missing-input-response
- input-required-result-ignore-extra-params
# Intentionally NOT baselined (2 of 19 draft scenarios): the SEP-2322
# negative-case scenarios input-required-result-unsupported-methods and
# input-required-result-validate-input pass today only because the stateful
# server's -32600 "Missing session ID" satisfies their assertions. They will
# start failing for real once stateless mode lands; add them then.
# SEP-2663 (io.modelcontextprotocol/tasks): the SDK does not implement the
# tasks extension yet. The bare `--suite all` leg is the only one that
# reaches these extension-tagged scenarios (they never match a
# --spec-version filter), so these entries live here only.
#
# `tasks-status-notifications` is intentionally NOT listed: the harness
# skips it unconditionally (pending its rewrite against subscriptions/
# listen), and a baseline entry for a scenario with no failing checks is
# flagged stale.
- tasks-lifecycle
- tasks-capability-negotiation
- tasks-wire-fields
- tasks-request-state-removal
- tasks-mrtr-input
- tasks-request-headers
- tasks-dispatch-and-envelope
- tasks-required-task-error
- tasks-mrtr-composition

108
.github/actions/conformance/run-client.sh vendored Executable file
View file

@ -0,0 +1,108 @@
#!/bin/bash
# Run a client conformance suite, re-verifying unexpected failures solo.
# Concurrent suite runs on a hosted runner can push scenarios with real-time
# waits past tolerance; solo, a real failure fails again while a contention
# artifact passes. Failures that only reproduce under concurrency are excused.
set -uo pipefail
: "${CONFORMANCE_PKG:?set CONFORMANCE_PKG (pinned in .github/workflows/conformance.yml)}"
# One attempt: a solo failure on the quiet runner disproves the contention
# hypothesis; a second try would be the blind retry this script avoids.
SOLO_ATTEMPTS="${CONFORMANCE_SOLO_ATTEMPTS:-1}"
# Relative args resolve from the repo root; same contract as run-server.sh.
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR/../../.." || exit 1
log="$(mktemp)"
trap 'rm -f "$log"' EXIT
npx --yes "$CONFORMANCE_PKG" client "$@" 2>&1 | tee "$log"
rc=${PIPESTATUS[0]}
if [ "$rc" -eq 0 ]; then
exit 0
fi
plain="$(sed 's/\x1b\[[0-9;]*m//g' "$log")"
# If the harness's summary wording changes, the list comes up empty and the
# original exit code passes through - never a false green.
mapfile -t scenarios < <(
printf '%s\n' "$plain" |
sed -n '/^Unexpected failures (not in baseline):$/,/^$/p' |
sed -n 's/^ ✗ //p'
)
if [ "${#scenarios[@]}" -eq 0 ]; then
exit "$rc"
fi
for scenario in "${scenarios[@]}"; do
if ! [[ "$scenario" =~ ^[A-Za-z0-9/_-]+$ ]]; then
echo "Extracted unexpected-failure name '${scenario}' does not look like a scenario name; passing the suite failure through." >&2
exit "$rc"
fi
done
# A stale baseline entry is a configuration error a solo rerun cannot excuse.
# Here-string, not a pipe: grep -q quitting early would SIGPIPE printf and,
# under pipefail, skip this guard exactly when the pattern is present.
if grep -q '^Stale baseline entries' <<<"$plain"; then
echo "Suite also reported stale baseline entries; not retrying." >&2
exit "$rc"
fi
# Drop the suite-only flags: --scenario replaces --suite, and solo runs are
# judged directly rather than against the baseline.
rerun_args=()
output_dir=""
skip_next=0
expect_output_dir=0
for arg in "$@"; do
if [ "$skip_next" -eq 1 ]; then
if [ "$expect_output_dir" -eq 1 ]; then
output_dir="$arg"
fi
skip_next=0
expect_output_dir=0
continue
fi
case "$arg" in
--output-dir)
skip_next=1
expect_output_dir=1
;;
--suite | --expected-failures) skip_next=1 ;;
--output-dir=*) output_dir="${arg#--output-dir=}" ;;
--suite=* | --expected-failures=*) ;;
*) rerun_args+=("$arg") ;;
esac
done
if [ -n "$output_dir" ]; then
rerun_args+=(--output-dir "${output_dir}-solo")
fi
for scenario in "${scenarios[@]}"; do
passed=0
for attempt in $(seq 1 "$SOLO_ATTEMPTS"); do
echo ""
echo "Re-running '${scenario}' solo (attempt ${attempt}/${SOLO_ATTEMPTS})..."
if npx --yes "$CONFORMANCE_PKG" client --scenario "$scenario" "${rerun_args[@]}"; then
passed=1
break
fi
done
if [ "$passed" -ne 1 ]; then
echo "'${scenario}' still fails when run alone: real failure, not suite contention." >&2
exit 1
fi
done
if [ -n "$output_dir" ]; then
mkdir -p "$output_dir"
printf '%s\n' "${scenarios[@]}" > "$output_dir/FLAKE_RESCUED"
fi
echo "All ${#scenarios[@]} unexpected failure(s) passed when re-run solo; the suite failures were parallel-run contention."
# Surface the rescue on the run summary; the log line above and the
# FLAKE_RESCUED marker in the uploaded results are otherwise easy to miss on a
# green run.
echo "::warning title=Conformance flake rescued::${scenarios[*]} failed under suite concurrency and passed solo${output_dir:+ (see ${output_dir}-solo and FLAKE_RESCUED in the uploaded results)}."
exit 0

View file

@ -47,5 +47,5 @@ done
echo "Server ready at $SERVER_URL"
npx --yes @modelcontextprotocol/conformance@"${CONFORMANCE_VERSION:?set CONFORMANCE_VERSION (pinned in .github/workflows/conformance.yml)}" \
npx --yes "${CONFORMANCE_PKG:?set CONFORMANCE_PKG (pinned in .github/workflows/conformance.yml)}" \
server --url "$SERVER_URL" "$@"

View file

@ -1,9 +1,21 @@
version: 2
updates:
- package-ecosystem: "uv"
directory: "/"
schedule:
interval: monthly
cooldown:
default-days: 14
groups:
python-packages:
patterns:
- "*"
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: monthly
cooldown:
default-days: 14
groups:
github-actions:
patterns:

38
.github/pull_request_template.md vendored Normal file
View file

@ -0,0 +1,38 @@
<!--
Pull requests from outside the maintainer team need to link an open issue that
a maintainer has assigned to you (or one labeled `help wanted`); others are
closed automatically until that's in place. See CONTRIBUTING.md for details.
-->
Fixes #
<!-- Provide a brief summary of your changes -->
## Motivation and Context
<!-- Why is this change needed? What problem does it solve? -->
## How Has This Been Tested?
<!-- Have you tested this in a real application? Which scenarios were tested? -->
## Breaking Changes
<!-- Will users need to update their code or configurations? -->
## Types of changes
<!-- What types of changes does your code introduce? Put an `x` in all the boxes that apply: -->
- [ ] Bug fix (non-breaking change which fixes an issue)
- [ ] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing functionality to change)
- [ ] Documentation update
## Checklist
<!-- Go over all the following points, and put an `x` in all the boxes that apply. -->
- [ ] I am assigned to the linked issue (or it is labeled `help wanted`, or I'm a maintainer)
- [ ] I have disclosed any AI assistance and can explain the change in my own words
- [ ] I have read the [MCP Documentation](https://modelcontextprotocol.io)
- [ ] My code follows the repository's style guidelines
- [ ] New and existing tests pass locally
- [ ] I have added appropriate error handling
- [ ] I have added or updated documentation as needed
## Additional context
<!-- Add any other context, implementation notes, or design decisions -->

117
.github/scripts/docs_preview.js vendored Normal file
View file

@ -0,0 +1,117 @@
// Docs preview gate and PR comment. .github/workflows/docs-preview.yml wires
// these up: `authorize` decides whether a run may build and deploy a preview
// (and for which commit), `comment` reports the outcome on the pull request.
// The security model is described in the workflow's header.
'use strict';
const MARKER = '<!-- docs-preview -->';
const BOT_LOGIN = 'github-actions[bot]';
// Sets the job outputs `authorized`, `pr_number`, `head_sha` and
// `slash_attempt` for a pull_request_target or /preview-docs issue_comment run.
async function authorize({ github, context, core }) {
const { owner, repo } = context.repo;
async function permissionFor(username) {
const { data } = await github.rest.repos.getCollaboratorPermissionLevel({ owner, repo, username });
return { level: data.permission, role: data.role_name };
}
let authorized = false;
let prNumber = '';
let headSha = '';
let slashAttempt = false;
if (context.eventName === 'pull_request_target') {
const pr = context.payload.pull_request;
prNumber = String(pr.number);
headSha = pr.head.sha;
// No automatic preview for fork PRs: actions/checkout refuses to fetch a
// fork's head in a pull_request_target run. A maintainer can still request
// one with /preview-docs. (head.repo is null once the fork is deleted.)
const headRepo = pr.head.repo;
if (!headRepo || headRepo.id !== context.payload.repository.id) {
core.info(`PR #${prNumber} head is on ${headRepo ? headRepo.full_name : 'a deleted fork'}; fork PRs are previewed via /preview-docs only.`);
} else {
// Gate on the *sender* (whoever caused this run — on synchronize that
// is the pusher), not the PR author, so a non-admin pushing to an
// admin-opened branch does not get an automatic build.
const actor = context.payload.sender.login;
const perm = await permissionFor(actor);
authorized = perm.level === 'admin';
core.info(`pull_request_target by ${actor} (level=${perm.level}, role=${perm.role}) → authorized=${authorized}`);
}
} else {
// issue_comment: the job-level `if:` already guarantees this is a PR
// comment starting with /preview-docs.
slashAttempt = true;
const actor = context.payload.comment.user.login;
prNumber = String(context.payload.issue.number);
const perm = await permissionFor(actor);
authorized = perm.level === 'admin' || perm.role === 'maintain';
if (authorized) {
const { data: pr } = await github.rest.pulls.get({ owner, repo, pull_number: Number(prNumber) });
if (pr.state !== 'open') {
authorized = false;
core.info(`PR #${prNumber} is ${pr.state}; refusing to preview.`);
} else {
headSha = pr.head.sha;
}
}
core.info(`/preview-docs by ${actor} (level=${perm.level}, role=${perm.role}) → authorized=${authorized}`);
}
core.setOutput('authorized', String(authorized));
core.setOutput('pr_number', prNumber);
core.setOutput('head_sha', headSha);
core.setOutput('slash_attempt', String(slashAttempt));
}
// Posts or updates the preview comment on the PR. Reads the outcome of the
// earlier jobs from the step's env: AUTHORIZED, PR_NUMBER, HEAD_SHA,
// DEPLOY_RESULT, DEPLOYMENT_URL, ALIAS_URL, RUN_URL.
async function comment({ github, context }) {
const { owner, repo } = context.repo;
const env = process.env;
const issue_number = Number(env.PR_NUMBER);
async function upsert(body) {
const comments = await github.paginate(github.rest.issues.listComments, { owner, repo, issue_number, per_page: 100 });
const existing = comments.find((c) => c.user?.login === BOT_LOGIN && c.body?.includes(MARKER));
if (existing) {
await github.rest.issues.updateComment({ owner, repo, comment_id: existing.id, body });
} else {
await github.rest.issues.createComment({ owner, repo, issue_number, body });
}
}
if (env.AUTHORIZED !== 'true') {
await github.rest.issues.createComment({
owner, repo, issue_number,
body: `@${context.actor} — only repository admins or maintainers can run \`/preview-docs\` (and the PR must be open).`,
});
return;
}
if (env.DEPLOY_RESULT !== 'success') {
await upsert(
`${MARKER}\n### 📚 Documentation preview\n\n` +
`❌ Preview build **failed** for \`${env.HEAD_SHA.slice(0, 7)}\` — [workflow logs](${env.RUN_URL}).`
);
return;
}
const previewUrl = env.ALIAS_URL || env.DEPLOYMENT_URL;
const ts = new Date().toISOString().replace('T', ' ').replace(/\.\d+Z$/, ' UTC');
await upsert(
`${MARKER}\n### 📚 Documentation preview\n\n` +
`| | |\n|---|---|\n` +
`| **Preview** | ${previewUrl} |\n` +
`| **Deployment** | ${env.DEPLOYMENT_URL} |\n` +
`| **Commit** | \`${env.HEAD_SHA.slice(0, 7)}\` |\n` +
`| **Triggered by** | @${context.actor} |\n` +
`| **Updated** | ${ts} |\n`
);
}
module.exports = { authorize, comment };

241
.github/scripts/docs_preview.test.js vendored Normal file
View file

@ -0,0 +1,241 @@
// Scenario tests for docs_preview.js: who gets a preview build (`authorize`)
// and what the pull request shows afterwards (`comment`).
//
// node --test .github/scripts/docs_preview.test.js
//
// No dependencies; the GitHub client is a small fake defined at the bottom.
// CI runs it in the checks job (.github/workflows/shared.yml).
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const { authorize, comment } = require('./docs_preview.js');
const REPO = { owner: 'modelcontextprotocol', repo: 'python-sdk' };
const BASE_REPO = { id: 1, full_name: 'modelcontextprotocol/python-sdk' };
const HEAD = 'e4dfda7baa127ab00ebcd1d5324560cbe3cdfe42';
const MARKER = '<!-- docs-preview -->';
// `permission` / `role_name` as the collaborators API reports them.
const PEOPLE = {
admin: { permission: 'admin', role_name: 'admin' },
maintainer: { permission: 'write', role_name: 'maintain' },
writer: { permission: 'write', role_name: 'write' },
outsider: { permission: 'read', role_name: 'read' },
};
// ── authorize ──────────────────────────────────────────────────────────────
// `expect` is the full set of job outputs the step writes.
const authorizeScenarios = [
{
name: 'admin pushes to (or opens) a PR → automatic preview of that head',
event: pushed(7, 'admin'),
expect: { authorized: 'true', pr_number: '7', head_sha: HEAD, slash_attempt: 'false' },
},
{
name: 'someone with write but not admin pushes → no automatic preview',
event: pushed(7, 'writer'),
expect: { authorized: 'false', pr_number: '7', head_sha: HEAD, slash_attempt: 'false' },
},
{
// actions/checkout refuses a fork's head under pull_request_target, so
// the run stops here instead of failing in `build`; /preview-docs still works.
name: 'admin pushes to or reopens a fork PR → no automatic preview',
event: pushed(7, 'admin', { fork: 'someone/python-sdk' }),
expect: { authorized: 'false', pr_number: '7', head_sha: HEAD, slash_attempt: 'false' },
permissionLookups: 0,
},
{
name: 'fork PR whose fork has since been deleted → no automatic preview',
event: pushed(7, 'admin', { fork: null }),
expect: { authorized: 'false', pr_number: '7', head_sha: HEAD, slash_attempt: 'false' },
permissionLookups: 0,
},
{
name: 'maintainer comments /preview-docs on a fork PR → previewed like any other',
pr: { fork: 'someone/python-sdk' },
event: slash(7, 'maintainer'),
expect: { authorized: 'true', pr_number: '7', head_sha: HEAD, slash_attempt: 'true' },
},
{
name: 'maintainer comments /preview-docs on an open PR → preview of its current head',
event: slash(7, 'maintainer'),
expect: { authorized: 'true', pr_number: '7', head_sha: HEAD, slash_attempt: 'true' },
},
{
name: 'admin comments /preview-docs → authorized as well',
event: slash(7, 'admin'),
expect: { authorized: 'true', pr_number: '7', head_sha: HEAD, slash_attempt: 'true' },
},
{
name: 'writer without the maintain role comments /preview-docs → refused, recorded as an attempt',
event: slash(7, 'writer'),
expect: { authorized: 'false', pr_number: '7', head_sha: '', slash_attempt: 'true' },
},
{
name: 'outsider comments /preview-docs → refused, recorded as an attempt',
event: slash(7, 'outsider'),
expect: { authorized: 'false', pr_number: '7', head_sha: '', slash_attempt: 'true' },
},
{
name: '/preview-docs on a closed PR → refused even for a maintainer',
pr: { state: 'closed' },
event: slash(7, 'maintainer'),
expect: { authorized: 'false', pr_number: '7', head_sha: '', slash_attempt: 'true' },
},
];
for (const s of authorizeScenarios) {
test(`authorize: ${s.name}`, async () => {
const world = makeWorld({ pr: { number: 7, ...s.pr } });
assert.deepEqual(await runAuthorize(world, s.event), s.expect);
assert.equal(world.writes.length, 0);
if (s.permissionLookups !== undefined) assert.equal(world.permissionLookups, s.permissionLookups);
});
}
test('authorize: a failing permission lookup fails the step instead of deciding either way', async () => {
const world = makeWorld({ pr: { number: 7 } });
world.failPermissionLookup = true;
await assert.rejects(runAuthorize(world, pushed(7, 'admin')), /boom/);
});
// ── comment ────────────────────────────────────────────────────────────────
const DEPLOYED = {
AUTHORIZED: 'true',
PR_NUMBER: '7',
HEAD_SHA: HEAD,
DEPLOY_RESULT: 'success',
DEPLOYMENT_URL: 'https://1a2b3c.mcp-python-sdk-docs.pages.dev',
ALIAS_URL: 'https://pr-7.mcp-python-sdk-docs.pages.dev',
RUN_URL: 'https://github.com/modelcontextprotocol/python-sdk/actions/runs/1',
};
test('comment: a refused /preview-docs gets a plain reply to the commenter, not a preview comment', async () => {
const world = makeWorld({ pr: { number: 7 } });
await runComment(world, { ...DEPLOYED, AUTHORIZED: 'false', HEAD_SHA: '', DEPLOY_RESULT: 'skipped' }, 'outsider');
assert.equal(world.comments.length, 1);
assert.match(world.comments[0].body, /^@outsider — only repository admins or maintainers can run `\/preview-docs`/);
assert.ok(!world.comments[0].body.includes(MARKER));
});
test('comment: first successful deploy posts one preview comment linking the alias URL and the commit', async () => {
const world = makeWorld({ pr: { number: 7 } });
await runComment(world, DEPLOYED, 'admin');
assert.equal(world.comments.length, 1);
const body = world.comments[0].body;
assert.ok(body.startsWith(`${MARKER}\n### 📚 Documentation preview`));
assert.match(body, /\| \*\*Preview\*\* \| https:\/\/pr-7\.mcp-python-sdk-docs\.pages\.dev \|/);
assert.match(body, /\| \*\*Deployment\*\* \| https:\/\/1a2b3c\.mcp-python-sdk-docs\.pages\.dev \|/);
assert.match(body, /\| \*\*Commit\*\* \| `e4dfda7` \|/);
assert.match(body, /\| \*\*Triggered by\*\* \| @admin \|/);
assert.deepEqual(world.writes, ['comment on #7']);
});
test('comment: a later deploy edits the existing preview comment instead of adding another', async () => {
const world = makeWorld({ pr: { number: 7 }, comments: [{ user: 'someone', body: 'LGTM' }, { user: 'github-actions[bot]', body: `${MARKER}\nold table` }] });
await runComment(world, { ...DEPLOYED, HEAD_SHA: 'f'.repeat(40) }, 'admin');
assert.equal(world.comments.length, 2);
assert.match(world.comments[1].body, /\| \*\*Commit\*\* \| `fffffff` \|/);
assert.deepEqual(world.writes, ['edit comment 101']);
});
test("comment: someone else's comment that happens to contain the marker is left alone", async () => {
const world = makeWorld({ pr: { number: 7 }, comments: [{ user: 'someone', body: `quoting ${MARKER} here` }] });
await runComment(world, DEPLOYED, 'admin');
assert.equal(world.comments.length, 2);
assert.equal(world.comments[0].body, `quoting ${MARKER} here`);
assert.deepEqual(world.writes, ['comment on #7']);
});
test('comment: with no alias URL the preview link falls back to the deployment URL', async () => {
const world = makeWorld({ pr: { number: 7 } });
await runComment(world, { ...DEPLOYED, ALIAS_URL: '' }, 'admin');
assert.match(world.comments[0].body, /\| \*\*Preview\*\* \| https:\/\/1a2b3c\.mcp-python-sdk-docs\.pages\.dev \|/);
});
test('comment: a build or deploy that did not succeed is reported with the short SHA and a link to the run', async () => {
const world = makeWorld({ pr: { number: 7 }, comments: [{ user: 'github-actions[bot]', body: `${MARKER}\nold table` }] });
await runComment(world, { ...DEPLOYED, DEPLOY_RESULT: 'skipped', DEPLOYMENT_URL: '', ALIAS_URL: '' }, 'admin');
assert.equal(world.comments.length, 1);
assert.equal(
world.comments[0].body,
`${MARKER}\n### 📚 Documentation preview\n\n❌ Preview build **failed** for \`e4dfda7\` — [workflow logs](${DEPLOYED.RUN_URL}).`
);
});
// ── Harness ────────────────────────────────────────────────────────────────
// `fork`: full name of the fork the head lives on; null for a deleted fork; omitted for a same-repo branch.
function pushed(number, sender, { fork } = {}) {
const repo = fork === undefined ? BASE_REPO : fork === null ? null : { id: 2, full_name: fork };
return { eventName: 'pull_request_target', actor: sender, payload: { action: 'synchronize', repository: BASE_REPO, pull_request: { number, head: { sha: HEAD, repo } }, sender: { login: sender } } };
}
function slash(number, commenter) {
return { eventName: 'issue_comment', actor: commenter, payload: { action: 'created', issue: { number, pull_request: {} }, comment: { body: '/preview-docs', user: { login: commenter } } } };
}
async function runAuthorize(world, event) {
const outputs = {};
const core = { info: () => {}, setOutput: (k, v) => { outputs[k] = v; } };
await authorize({ github: world.github, context: { repo: REPO, ...event }, core });
return outputs;
}
async function runComment(world, env, actor) {
const saved = {};
for (const [k, v] of Object.entries(env)) { saved[k] = process.env[k]; process.env[k] = v; }
try {
await comment({ github: world.github, context: { repo: REPO, actor }, core: {} });
} finally {
for (const [k, v] of Object.entries(saved)) { if (v === undefined) delete process.env[k]; else process.env[k] = v; }
}
}
// ── A tiny in-memory GitHub ────────────────────────────────────────────────
function makeWorld({ pr, comments = [] }) {
const world = { pr: { state: 'open', ...pr }, comments: [], writes: [], failPermissionLookup: false, permissionLookups: 0, nextCommentId: 100 };
for (const c of comments) world.comments.push({ id: world.nextCommentId++, ...c });
const err = (status, message = 'fake error') => Object.assign(new Error(message), { status });
const write = (what) => world.writes.push(what);
const checkPr = (n) => { if (n !== world.pr.number) throw err(404); };
const rest = {
repos: {
getCollaboratorPermissionLevel: async ({ username }) => {
world.permissionLookups++;
if (world.failPermissionLookup) throw err(500, 'boom');
const person = PEOPLE[username];
if (!person) throw err(404, 'not a user');
return { data: { ...person, user: { login: username } } };
},
},
pulls: {
get: async ({ pull_number }) => {
checkPr(pull_number);
const repo = world.pr.fork ? { id: 2, full_name: world.pr.fork } : BASE_REPO;
return { data: { number: pull_number, state: world.pr.state, head: { sha: HEAD, repo } } };
},
},
issues: {
listComments: async ({ issue_number }) => { checkPr(issue_number); return { data: world.comments.map((c) => ({ id: c.id, body: c.body, user: { login: c.user } })) }; },
createComment: async ({ issue_number, body }) => {
checkPr(issue_number);
write(`comment on #${issue_number}`);
world.comments.push({ id: world.nextCommentId++, user: 'github-actions[bot]', body });
},
updateComment: async ({ comment_id, body }) => {
write(`edit comment ${comment_id}`);
const c = world.comments.find((x) => x.id === comment_id);
if (!c) throw err(404);
c.body = body;
},
},
};
world.github = { rest, paginate: async (fn, args) => (await fn(args)).data };
return world;
}

312
.github/scripts/pr_intake_gate.js vendored Normal file
View file

@ -0,0 +1,312 @@
// PR intake gate. The policy lives in CONTRIBUTING.md ("How pull requests get
// in"); .github/workflows/require-linked-issue.yml wires this up to events.
//
// A pull request from someone without triage rights stays open only if its
// description links (Fixes/Closes/Resolves #N) an open issue in this repo that
// is either assigned to the PR author or labeled `help wanted`. Otherwise the
// gate labels it `missing-issue-link`, leaves one comment, and closes it. It
// re-evaluates — and reopens — the PR when the description is edited or the
// author is assigned to the issue. A triage+ user reopening the PR, removing
// the label, or adding `bypass-issue-check` overrides it, and the override
// sticks.
//
// Everything that writes goes through mutate(); when the workflow passes
// ENFORCE=false (its kill switch) the run only logs what it would have done.
'use strict';
const LABEL = 'missing-issue-link'; // marks PRs the gate has closed
const BYPASS_LABEL = 'bypass-issue-check'; // sticky maintainer override
const OPEN_LABEL = 'help wanted'; // issue label that waives assignment
const MARKER = '<!-- require-linked-issue -->';
const BOT_LOGIN = 'github-actions[bot]';
const MAX_ISSUES = 5;
module.exports = async function run({ github, context, core }) {
const { owner, repo } = context.repo;
const enforce = process.env.ENFORCE === 'true';
const contributingUrl = `https://github.com/${owner}/${repo}/blob/main/CONTRIBUTING.md#how-pull-requests-get-in`;
// ── Entry points ─────────────────────────────────────────────────────────
if (context.eventName === 'issues') {
// Someone was assigned an issue: re-evaluate their gate-closed PRs that
// reference it (they may pass now).
const issueNumber = context.payload.issue.number;
const assignee = context.payload.assignee.login;
const closed = await github.paginate(github.rest.issues.listForRepo, {
owner, repo, state: 'closed', creator: assignee, labels: LABEL, per_page: 100,
});
const prs = closed.filter((i) => i.pull_request && closingRefs(i.body).includes(issueNumber));
console.log(`#${issueNumber} assigned to ${assignee}: ${prs.length} gate-closed PR(s) reference it`);
// Evaluate each independently so one transient failure doesn't strand
// the rest (this event won't fire again for the same assignment).
const failures = [];
for (const pr of prs) {
try {
await evaluate(pr.number, 'assigned', context.payload.sender?.login, issueNumber);
} catch (e) {
failures.push(`#${pr.number}: ${e.message}`);
}
}
if (failures.length) throw new Error(`Could not re-evaluate ${failures.join('; ')}`);
return;
}
if (context.eventName === 'workflow_dispatch') {
const n = parseInt(process.env.PR_NUMBER_INPUT, 10);
if (!Number.isInteger(n) || n <= 0) throw new Error(`Bad pr_number input: ${process.env.PR_NUMBER_INPUT}`);
await evaluate(n, 'dispatch', context.payload.sender?.login);
return;
}
await evaluate(context.payload.pull_request.number, context.payload.action, context.payload.sender?.login);
// ── The rules ────────────────────────────────────────────────────────────
async function evaluate(prNumber, action, sender, hintIssue = null) {
// Always read the PR live; the event payload can be stale by the time a
// queued run starts.
const { data: pr } = await github.rest.pulls.get({ owner, repo, pull_number: prNumber });
const labels = pr.labels.map((l) => l.name);
// An `unlabeled` run only fires for LABEL (see the workflow `if:`), so the
// event itself proves the label was there a moment ago.
const gated = action === 'unlabeled' || labels.includes(LABEL);
console.log(`PR #${prNumber} by ${pr.user.login} (${pr.state}${pr.draft ? ', draft' : ''}) — ${action} by ${sender ?? '-'}, enforce=${enforce}`);
// 0. Scope: open PRs, plus closed PRs the gate closed itself. Merged PRs
// and PRs someone closed for other reasons are left alone.
if (pr.merged_at) return log('merged — nothing to do');
if (pr.state === 'closed' && !gated) return log('closed by someone else — not ours');
// 1. Exempt authors: bots and anyone with triage or better. Drafts are
// gated like any other PR.
if (pr.user.type === 'Bot') return log('author is a bot — exempt');
if (await isTrusted(pr.user.login)) return pass('author has triage+ on this repo');
// 2. Overrides: a triage+ user reopening the PR or removing the label wants
// it open. Anyone else doing so just triggers a re-check.
if ((action === 'reopened' || action === 'unlabeled') && sender && (await isTrusted(sender))) {
return pass(`${sender} ${action === 'reopened' ? 'reopened it' : 'removed the label'} — override`, { sticky: true });
}
if (labels.includes(BYPASS_LABEL)) return pass(`carries ${BYPASS_LABEL}`);
// 3. The rule: the description links an open issue in this repo that is
// labeled `help wanted` or assigned to the author. Only the first few
// references are fetched; a just-assigned issue is checked first.
const author = pr.user.login.toLowerCase();
const refs = closingRefs(pr.body);
if (hintIssue && refs.includes(hintIssue)) refs.unshift(...refs.splice(refs.indexOf(hintIssue), 1));
const linked = [];
for (const num of refs.slice(0, MAX_ISSUES)) {
const issue = await getIssue(num);
if (!issue) continue; // missing, a PR, closed, or transferred away
linked.push(num);
if (issue.labels.some((l) => l.name.toLowerCase() === OPEN_LABEL)) return pass(`#${num} is labeled "${OPEN_LABEL}"`);
if (issue.assignees.some((a) => a.login.toLowerCase() === author)) return pass(`author is assigned to #${num}`);
}
return fail(linked);
// ── Outcomes ─────────────────────────────────────────────────────────
async function pass(reason, { sticky = false } = {}) {
console.log(`PASS: ${reason}`);
if (sticky) await addLabel(prNumber, BYPASS_LABEL);
if (pr.state === 'closed' && !(await reopen(pr, reason))) return;
if (gated) {
await removeLabel(prNumber, LABEL);
await deleteGateComment(prNumber);
}
}
async function fail(linkedIssues) {
console.log(`FAIL: ${linkedIssues.length ? `not assigned to ${linkedIssues.map((n) => `#${n}`).join(', ')}` : 'no usable issue link'}`);
await addLabel(prNumber, LABEL);
await upsertGateComment(prNumber, closedComment(pr.draft, linkedIssues));
if (pr.state === 'open') {
await mutate(`close PR #${prNumber}`, () => github.rest.pulls.update({ owner, repo, pull_number: prNumber, state: 'closed' }));
}
}
function log(msg) {
console.log(msg);
}
}
// ── Comment text ─────────────────────────────────────────────────────────
function closedComment(draft, linkedIssues) {
const issues = linkedIssues.map((n) => `#${n}`).join(', ');
const opener = draft
? "This PR has been closed automatically. It's still a draft, but we close those early so you don't put in more time only to have it closed the moment you mark it ready.\n\n"
: 'This PR has been closed automatically. ';
const rule = `${opener}This repo only keeps pull requests open when they come from a maintainer, or from a contributor a maintainer has assigned to the linked issue`;
const situation = linkedIssues.length
? [
`${rule}, and you aren't currently assigned to ${issues}.`,
'',
`If a maintainer assigns you to ${issues}, this PR reopens on its own and there's nothing more you need to do here. Assignment is a maintainer call based on capacity; comments that only ask to be assigned don't factor in. What does help is engaging on the issue itself by confirming the repro, explaining why it matters for your use case, or describing the approach you'd take.`,
]
: [
`${rule}, and this PR doesn't link an open issue yet.`,
'',
"- **If you're already assigned to an issue for this**, add `Fixes #<n>` to the description and the PR will reopen on its own.",
`- **If there's no issue yet**, please [open one](https://github.com/${owner}/${repo}/issues/new/choose) instead: what you ran into, why it matters for your use case, and a minimal reproduction. That context is super important to us and is what we use to decide what to prioritise.`,
"- **If there's an issue but you're not assigned**, add `Fixes #<n>` anyway so they're linked, then engage on the issue itself by confirming the repro or describing the approach you'd take. Assignment is a maintainer call based on capacity; comments that only ask to be assigned don't factor in. If you are assigned, this PR reopens automatically.",
];
return [
MARKER,
...situation,
'',
"You're welcome to keep pushing commits here (just avoid force-pushing, since GitHub can't reopen a rewritten branch), but that on its own won't get the PR reviewed or the issue assigned, and realistically most auto-closed PRs stay closed. There's no need to open a new PR either way.",
'',
`[CONTRIBUTING.md](${contributingUrl}) has the full reasoning, but in short:`,
'',
"- We're a small team with very little capacity to review community PRs right now.",
'- Many recent PRs are AI-generated with little human review, and reviewing one carefully still costs a maintainer as much time as it ever did. A well-described issue is usually more useful to us than the code.',
'',
`*Maintainers: reopen, remove \`${LABEL}\`, or add \`${BYPASS_LABEL}\` to override.*`,
].join('\n');
}
function cannotReopenComment(pr, reason) {
return [
MARKER,
`This PR now passes the intake check (${reason}), but GitHub won't let it be reopened — usually because the branch was force-pushed or deleted while the PR was closed, or because another open PR uses the same branch.`,
'',
`If you have another open PR from this branch, please continue there. Otherwise, either push the branch back to \`${pr.head.sha.slice(0, 7)}\` and edit this PR's description to retry, or open a new PR that links the same issue (if a maintainer had waved this one through, mention that so they can do the same there).`,
].join('\n');
}
// ── Helpers ──────────────────────────────────────────────────────────────
async function mutate(description, fn) {
if (!enforce) {
console.log(`[dry-run] would ${description}`);
return undefined;
}
return fn();
}
// Triage-or-better on this repo, from the permission endpoint's capability
// flags (role names can be custom; author_association hides private org
// members). Only a nonexistent user 404s; any other error must throw rather
// than be read as "untrusted", or a maintainer's PR could be closed.
async function isTrusted(username) {
try {
const { data } = await github.rest.repos.getCollaboratorPermissionLevel({ owner, repo, username });
const p = data.user?.permissions;
if (!p) throw new Error(`permission response for ${username} has no capability flags`);
const trusted = Boolean(p.triage || p.push || p.maintain || p.admin);
console.log(` ${username}: ${trusted ? 'trusted' : 'not trusted'} (role ${data.role_name || '-'})`);
return trusted;
} catch (e) {
if (e.status === 404) return false;
throw new Error(`Permission check failed for ${username} (HTTP ${e.status ?? '?'}): ${e.message}`);
}
}
// Issue numbers referenced with a closing keyword, in the forms GitHub itself
// honors: `Fixes #1`, `closes owner/repo#1`, `Resolved https://github.com/owner/repo/issues/1`.
function closingRefs(body) {
const repoRef = `${owner}/${repo}`.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const re = new RegExp(
`\\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\\s*:?\\s*(?:${repoRef}#|#|https?://github\\.com/${repoRef}/issues/)(\\d+)`,
'gi',
);
return [...new Set([...(body || '').matchAll(re)].map((m) => parseInt(m[1], 10)))];
}
// The linked issue, or null if it doesn't exist, is actually a PR, isn't
// open, or has been transferred to another repository.
async function getIssue(num) {
let issue;
try {
({ data: issue } = await github.rest.issues.get({ owner, repo, issue_number: num }));
} catch (e) {
if (e.status === 404 || e.status === 410) return null;
throw new Error(`Cannot fetch issue #${num} (HTTP ${e.status ?? '?'}): ${e.message}`);
}
if (issue.pull_request || issue.state !== 'open') return null;
if (!issue.repository_url?.endsWith(`/${owner}/${repo}`)) return null;
return issue;
}
// Reopen a gate-closed PR. GitHub refuses (422) if the branch was rewritten
// or deleted while closed, or another open PR uses it. Explain that in the
// comment and make sure the control label is (still) on, so the PR stays
// gate-managed and a later edit or override retries the reopen.
async function reopen(pr, reason) {
try {
await mutate(`reopen PR #${pr.number}`, () => github.rest.pulls.update({ owner, repo, pull_number: pr.number, state: 'open' }));
return true;
} catch (e) {
if (e.status !== 422) throw e;
core.warning(`GitHub refused to reopen PR #${pr.number}: ${e.message}`);
await addLabel(pr.number, LABEL);
await upsertGateComment(pr.number, cannotReopenComment(pr, reason));
return false;
}
}
async function addLabel(prNumber, name) {
await mutate(`add "${name}" to PR #${prNumber}`, async () => {
await ensureLabelExists(name);
await github.rest.issues.addLabels({ owner, repo, issue_number: prNumber, labels: [name] });
});
}
async function removeLabel(prNumber, name) {
await mutate(`remove "${name}" from PR #${prNumber}`, async () => {
try {
await github.rest.issues.removeLabel({ owner, repo, issue_number: prNumber, name });
} catch (e) {
if (e.status !== 404) throw e;
}
});
}
async function ensureLabelExists(name) {
const meta = {
[LABEL]: ['b76e79', 'Auto-closed: PR needs a linked issue assigned to its author (see CONTRIBUTING.md)'],
[BYPASS_LABEL]: ['0e8a16', 'Maintainer override for the linked-issue intake gate'],
}[name];
try {
await github.rest.issues.getLabel({ owner, repo, name });
} catch (e) {
if (e.status !== 404) throw e;
try {
await github.rest.issues.createLabel({ owner, repo, name, color: meta[0], description: meta[1] });
} catch (createErr) {
if (createErr.status !== 422) throw createErr; // created concurrently
}
}
}
// The gate keeps at most one comment per PR: authored by the Actions bot and
// carrying MARKER. It's created or updated on failure and deleted on pass.
async function findGateComment(prNumber) {
const comments = await github.paginate(github.rest.issues.listComments, { owner, repo, issue_number: prNumber, per_page: 100 });
return comments.find((c) => c.user?.login === BOT_LOGIN && c.body?.includes(MARKER));
}
async function upsertGateComment(prNumber, body) {
const existing = await findGateComment(prNumber);
if (!existing) {
await mutate(`comment on PR #${prNumber}`, () => github.rest.issues.createComment({ owner, repo, issue_number: prNumber, body }));
} else if (existing.body !== body) {
await mutate(`update the gate comment on PR #${prNumber}`, () => github.rest.issues.updateComment({ owner, repo, comment_id: existing.id, body }));
}
}
async function deleteGateComment(prNumber) {
const existing = await findGateComment(prNumber);
if (!existing) return;
await mutate(`delete the gate comment on PR #${prNumber}`, async () => {
try {
await github.rest.issues.deleteComment({ owner, repo, comment_id: existing.id });
} catch (e) {
if (e.status !== 404) throw e; // already deleted by a concurrent run
}
});
}
};

393
.github/scripts/pr_intake_gate.test.js vendored Normal file
View file

@ -0,0 +1,393 @@
// Scenario tests for pr_intake_gate.js. Each case sets up a tiny in-memory
// repository (pull requests, issues, people), fires one event at the gate, and
// checks what the gate left behind: PR state, labels, and its comment.
//
// node --test .github/scripts/pr_intake_gate.test.js
//
// No dependencies; the GitHub client is a small fake defined at the bottom.
// CI runs it in the checks job (.github/workflows/shared.yml).
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const gate = require('./pr_intake_gate.js');
const LABEL = 'missing-issue-link';
const BYPASS = 'bypass-issue-check';
const REPO = { owner: 'modelcontextprotocol', repo: 'python-sdk' };
// People. Only the capability flags matter to the gate.
const PEOPLE = {
maintainer: { type: 'User', perms: { admin: true, maintain: true, push: true, triage: true, pull: true } },
triager: { type: 'User', perms: { triage: true, pull: true } }, // e.g. a trusted-contributors team
outsider: { type: 'User', perms: { pull: true } },
'dependabot[bot]': { type: 'Bot', perms: {} },
'somebot[bot]': { type: 'Bot', perms: {} },
};
// ── Scenarios ──────────────────────────────────────────────────────────────
// `prs` / `issues` describe the world before the event; `expect` describes each
// PR afterwards: state, labels, and comment ('closed' = the "this PR has been
// closed" comment, 'closed-draft' = its draft wording, 'cannot-reopen' = the
// refused-reopen comment, null = none).
// `writes: 0` additionally asserts the gate touched nothing at all.
const scenarios = [
{
name: 'outsider opens a PR with no issue link → labeled, commented, closed',
prs: [pr(3300, 'outsider')],
event: opened(3300, 'outsider'),
expect: { 3300: { state: 'closed', labels: [LABEL], comment: 'closed' } },
},
{
name: 'outsider links an open issue reported by someone else, not assigned → closed',
prs: [pr(3300, 'outsider', { body: 'Fixes #10' })],
issues: [issue(10)],
event: opened(3300, 'outsider'),
expect: { 3300: { state: 'closed', labels: [LABEL], comment: 'closed' } },
},
{
name: 'outsider links an issue they are assigned to → stays open, untouched',
prs: [pr(3300, 'outsider', { body: 'closes #10' })],
issues: [issue(10, { assignees: ['outsider'] })],
event: opened(3300, 'outsider'),
expect: { 3300: { state: 'open', labels: [], comment: null } },
writes: 0,
},
{
name: 'outsider links a `help wanted` issue → stays open without assignment',
prs: [pr(3300, 'outsider', { body: 'Resolves modelcontextprotocol/python-sdk#10' })],
issues: [issue(10, { labels: ['help wanted'] })],
event: opened(3300, 'outsider'),
expect: { 3300: { state: 'open', labels: [], comment: null } },
writes: 0,
},
{
name: 'links only count when they use a closing keyword and point at an open issue here',
prs: [pr(3300, 'outsider', { body: 'Related to #10, fixes owner/other#10, prefixes #10, Fixes #11, Fixes #12' })],
issues: [
issue(10, { assignees: ['outsider'] }), // referenced, but never with a closing keyword for this repo
issue(11, { assignees: ['outsider'], state: 'closed' }), // closed issues don't count
issue(12, { assignees: ['outsider'], repository_url: 'https://api.github.com/repos/modelcontextprotocol/typescript-sdk' }), // transferred away
],
event: opened(3300, 'outsider'),
expect: { 3300: { state: 'closed', labels: [LABEL], comment: 'closed' } },
},
{
name: 'gate-closed PR: author edits the description to link a qualifying issue → reopened, label and comment removed',
prs: [pr(3300, 'outsider', { state: 'closed', labels: [LABEL], body: 'Fixes #10', gateComment: true })],
issues: [issue(10, { labels: ['help wanted'] })],
event: edited(3300, 'outsider'),
expect: { 3300: { state: 'open', labels: [], comment: null } },
},
{
name: 'gate-closed PR: maintainer assigns the author on the linked issue → reopened',
prs: [
pr(3300, 'outsider', { state: 'closed', labels: [LABEL], body: 'Fixes #10', gateComment: true }),
pr(3301, 'outsider', { state: 'closed', labels: [LABEL], body: 'Fixes #99', gateComment: true }), // different issue: untouched
],
issues: [issue(10, { assignees: ['outsider'] }), issue(99)],
event: assigned(10, 'outsider', 'maintainer'),
expect: {
3300: { state: 'open', labels: [], comment: null },
3301: { state: 'closed', labels: [LABEL], comment: 'closed' },
},
},
{
name: 'gate-closed PR: maintainer reopens it → stays open with the sticky bypass label',
prs: [pr(3300, 'outsider', { state: 'open', labels: [LABEL], gateComment: true })], // payload arrives post-reopen
event: reopened(3300, 'maintainer'),
expect: { 3300: { state: 'open', labels: [BYPASS], comment: null } },
},
{
name: 'gate-closed PR: triage-role user removes the label → reopened with the sticky bypass label',
prs: [pr(3300, 'outsider', { state: 'closed', labels: [], gateComment: true })], // payload arrives post-unlabel
event: unlabeled(3300, 'triager'),
expect: { 3300: { state: 'open', labels: [BYPASS], comment: null } },
},
{
name: 'gate-closed PR: some other bot strips the label → re-checked, label restored, still closed',
prs: [pr(3300, 'outsider', { state: 'closed', labels: [], gateComment: true })],
event: unlabeled(3300, 'somebot[bot]'),
expect: { 3300: { state: 'closed', labels: [LABEL], comment: 'closed' } },
},
{
name: 'PR carrying the bypass label is never re-closed, even after edits that would fail',
prs: [pr(3300, 'outsider', { labels: [BYPASS], body: 'no link at all' })],
event: edited(3300, 'outsider'),
expect: { 3300: { state: 'open', labels: [BYPASS], comment: null } },
writes: 0,
},
{
name: 'previously passing PR edited to drop its link → closed again',
prs: [pr(3300, 'outsider', { body: 'see #10' })],
issues: [issue(10, { assignees: ['outsider'] })],
event: edited(3300, 'outsider'),
expect: { 3300: { state: 'closed', labels: [LABEL], comment: 'closed' } },
},
{
name: "maintainer's own PR is exempt (and reopening it adds no labels)",
prs: [pr(3300, 'maintainer')],
event: reopened(3300, 'maintainer'),
expect: { 3300: { state: 'open', labels: [], comment: null } },
writes: 0,
},
{
name: 'triage-role author is exempt',
prs: [pr(3300, 'triager')],
event: opened(3300, 'triager'),
expect: { 3300: { state: 'open', labels: [], comment: null } },
writes: 0,
},
{
name: 'bot-authored PR (Dependabot) is exempt',
prs: [pr(3300, 'dependabot[bot]')],
event: opened(3300, 'dependabot[bot]'),
expect: { 3300: { state: 'open', labels: [], comment: null } },
writes: 0,
},
{
name: 'draft PR with no issue link is closed like any other, with a note about why drafts are closed early',
prs: [pr(3300, 'outsider', { draft: true })],
event: opened(3300, 'outsider'),
expect: { 3300: { state: 'closed', labels: [LABEL], comment: 'closed-draft' } },
},
{
name: 'pre-existing draft marked ready for review with no link → closed',
prs: [pr(3300, 'outsider')],
event: readyForReview(3300, 'outsider'),
expect: { 3300: { state: 'closed', labels: [LABEL], comment: 'closed' } },
},
{
name: 'PR a maintainer closed by hand (no gate label) is left alone when edited',
prs: [pr(3300, 'outsider', { state: 'closed' })],
event: edited(3300, 'outsider'),
expect: { 3300: { state: 'closed', labels: [], comment: null } },
writes: 0,
},
{
name: 'merged PR still carrying the label is left alone',
prs: [pr(3300, 'outsider', { state: 'closed', merged_at: '2026-08-15T00:00:00Z', labels: [LABEL] })],
event: edited(3300, 'outsider'),
expect: { 3300: { state: 'closed', labels: [LABEL], comment: null } },
writes: 0,
},
{
name: 'GitHub refuses to reopen (branch rewritten while closed) → stays closed, comment explains',
prs: [pr(3300, 'outsider', { state: 'closed', labels: [LABEL], body: 'Fixes #10', gateComment: true, refuseReopen: true })],
issues: [issue(10, { assignees: ['outsider'] })],
event: edited(3300, 'outsider'),
expect: { 3300: { state: 'closed', labels: [LABEL], comment: 'cannot-reopen' } },
},
{
name: 'gate-closed PR: maintainer adds the bypass label → reopened, and the label sticks',
prs: [pr(3300, 'outsider', { state: 'closed', labels: [LABEL, BYPASS], gateComment: true })], // payload arrives post-label
event: labeled(3300, 'maintainer', BYPASS),
expect: { 3300: { state: 'open', labels: [BYPASS], comment: null } },
},
{
name: 'refused reopen after a label-removal override → both labels on, so the PR stays gate-managed',
prs: [pr(3300, 'outsider', { state: 'closed', labels: [], gateComment: true, refuseReopen: true })],
event: unlabeled(3300, 'triager'),
expect: { 3300: { state: 'closed', labels: [BYPASS, LABEL], comment: 'cannot-reopen' } },
},
{
name: 'after a refused reopen, once the branch is restored an edit retries and reopens',
prs: [pr(3300, 'outsider', { state: 'closed', labels: [BYPASS, LABEL], comments: [{ user: 'github-actions[bot]', body: "<!-- require-linked-issue -->\n…GitHub won't let it be reopened…" }] })],
event: edited(3300, 'outsider'),
expect: { 3300: { state: 'open', labels: [BYPASS], comment: null } },
},
{
name: 'assignment reopens the PR even when the assigned issue is referenced after several others',
prs: [pr(3300, 'outsider', { state: 'closed', labels: [LABEL], body: 'Fixes #1 fixes #2 fixes #3 fixes #4 fixes #5 fixes #6', gateComment: true })],
issues: [issue(1), issue(2), issue(3), issue(4), issue(5), issue(6, { assignees: ['outsider'] })],
event: assigned(6, 'outsider', 'maintainer'),
expect: { 3300: { state: 'open', labels: [], comment: null } },
},
{
name: 'a comment planted by the author with the marker is ignored; the gate posts its own',
prs: [pr(3300, 'outsider', { comments: [{ user: 'outsider', body: '<!-- require-linked-issue -->\nnice try' }] })],
event: opened(3300, 'outsider'),
expect: { 3300: { state: 'closed', labels: [LABEL], comment: 'closed', foreignComments: 1 } },
},
{
name: 'manual dispatch evaluates an old, never-gated PR like a new one',
prs: [pr(3150, 'outsider')],
event: dispatch(3150, 'maintainer'),
expect: { 3150: { state: 'closed', labels: [LABEL], comment: 'closed' } },
},
];
for (const s of scenarios) {
test(s.name, async () => {
const world = makeWorld(s);
await run(world, s.event, { enforce: true });
assert.deepEqual(observe(world, s.expect), s.expect);
if (s.writes !== undefined) assert.equal(world.writes.length, s.writes, `writes: ${world.writes.join(', ')}`);
});
}
test('kill switch (ENFORCE=false) reaches the same verdicts but writes nothing', async () => {
for (const s of scenarios) {
const world = makeWorld(s);
await run(world, s.event, { enforce: false });
assert.equal(world.writes.length, 0, `${s.name}: ${world.writes.join(', ')}`);
}
});
test('on assignment, one PR failing to re-evaluate does not stop the others (run still fails)', async () => {
const world = makeWorld({
prs: [
pr(3300, 'outsider', { state: 'closed', labels: [LABEL], body: 'Fixes #10', gateComment: true }),
pr(3301, 'outsider', { state: 'closed', labels: [LABEL], body: 'Fixes #10', gateComment: true }),
],
issues: [issue(10, { assignees: ['outsider'] })],
});
world.failPullsGet = 3300;
await assert.rejects(run(world, assigned(10, 'outsider', 'maintainer'), { enforce: true }), /Could not re-evaluate #3300/);
assert.equal(world.prs.get(3301).state, 'open');
});
test('an API error while checking permissions fails the run rather than closing the PR', async () => {
const world = makeWorld({ prs: [pr(3300, 'outsider')] });
world.failPermissionLookup = true;
await assert.rejects(run(world, opened(3300, 'outsider'), { enforce: true }), /Permission check failed/);
assert.equal(world.writes.length, 0);
});
// ── Scenario helpers ───────────────────────────────────────────────────────
function pr(number, author, o = {}) {
return { number, author, state: 'open', draft: false, body: '', labels: [], merged_at: null, comments: [], gateComment: false, refuseReopen: false, ...o };
}
function issue(number, o = {}) {
return { number, state: 'open', labels: [], assignees: [], repository_url: `https://api.github.com/repos/${REPO.owner}/${REPO.repo}`, ...o };
}
function prEvent(action, number, sender, extra = {}) {
return { eventName: 'pull_request_target', payload: { action, pull_request: { number }, sender: { login: sender }, ...extra } };
}
function opened(number, sender) { return prEvent('opened', number, sender); }
function edited(number, sender) { return prEvent('edited', number, sender); }
function reopened(number, sender) { return prEvent('reopened', number, sender); }
function readyForReview(number, sender) { return prEvent('ready_for_review', number, sender); }
function unlabeled(number, sender) { return prEvent('unlabeled', number, sender, { label: { name: LABEL } }); }
function labeled(number, sender, name) { return prEvent('labeled', number, sender, { label: { name } }); }
function assigned(issueNumber, assignee, sender) {
return { eventName: 'issues', payload: { action: 'assigned', issue: { number: issueNumber }, assignee: { login: assignee }, sender: { login: sender } } };
}
function dispatch(number, sender) {
return { eventName: 'workflow_dispatch', prNumber: number, payload: { inputs: { pr_number: String(number) }, sender: { login: sender } } };
}
async function run(world, event, { enforce }) {
process.env.ENFORCE = enforce ? 'true' : 'false';
process.env.PR_NUMBER_INPUT = event.prNumber ? String(event.prNumber) : '';
const core = { warning: () => {}, setFailed: (m) => { throw new Error(`setFailed: ${m}`); } };
const quiet = console.log;
console.log = () => {};
try {
await gate({ github: world.github, context: { repo: REPO, ...event }, core });
} finally {
console.log = quiet;
}
}
// What the gate left behind, in the same shape as a scenario's `expect`.
function observe(world, expect) {
const out = {};
for (const num of Object.keys(expect)) {
const p = world.prs.get(Number(num));
const gateComments = p.comments.filter((c) => c.user === 'github-actions[bot]' && c.body.includes('<!-- require-linked-issue -->'));
assert.ok(gateComments.length <= 1, `PR #${num} has ${gateComments.length} gate comments`);
const body = gateComments[0]?.body;
const kind = !body ? null : body.includes("won't let it be reopened") ? 'cannot-reopen' : body.includes('still a draft') ? 'closed-draft' : 'closed';
out[num] = { state: p.state, labels: [...p.labels].sort(), comment: kind };
if ('foreignComments' in expect[num]) out[num].foreignComments = p.comments.length - gateComments.length;
}
return out;
}
// ── A tiny in-memory GitHub ────────────────────────────────────────────────
function makeWorld({ prs = [], issues = [] }) {
const world = { prs: new Map(), issues: new Map(), writes: [], failPermissionLookup: false, failPullsGet: null, nextCommentId: 100 };
for (const p of prs) {
const copy = structuredClone(p);
copy.comments = copy.comments.map((c) => ({ id: world.nextCommentId++, ...c }));
if (copy.gateComment) copy.comments.push({ id: world.nextCommentId++, user: 'github-actions[bot]', body: '<!-- require-linked-issue -->\nThanks for the contribution. …closed for now…' });
world.prs.set(p.number, copy);
}
for (const i of issues) world.issues.set(i.number, structuredClone(i));
const err = (status, message = 'fake error') => Object.assign(new Error(message), { status });
const write = (what) => world.writes.push(what);
const getPr = (n) => { const p = world.prs.get(n); if (!p) throw err(404); return p; };
const apiPr = (p) => ({
number: p.number, state: p.state, draft: p.draft, body: p.body, merged_at: p.merged_at,
labels: p.labels.map((name) => ({ name })),
user: { login: p.author, type: PEOPLE[p.author].type },
head: { sha: 'abc1234def', ref: 'patch-1', repo: { name: REPO.repo, owner: { login: p.author } } },
});
const rest = {
repos: {
getCollaboratorPermissionLevel: async ({ username }) => {
if (world.failPermissionLookup) throw err(500, 'boom');
const person = PEOPLE[username];
if (!person) throw err(404, 'not a user');
return { data: { role_name: 'custom', user: { permissions: { admin: false, maintain: false, push: false, triage: false, pull: false, ...person.perms } } } };
},
},
pulls: {
get: async ({ pull_number }) => { if (world.failPullsGet === pull_number) throw err(502, 'bad gateway'); return { data: apiPr(getPr(pull_number)) }; },
update: async ({ pull_number, state }) => {
const p = getPr(pull_number);
write(`${state === 'open' ? 'reopen' : 'close'} #${pull_number}`);
if (state === 'open' && p.refuseReopen) throw err(422, 'Validation Failed: state cannot be changed');
p.state = state;
return { data: apiPr(p) };
},
},
issues: {
get: async ({ issue_number }) => {
if (world.prs.has(issue_number)) return { data: { ...apiPr(getPr(issue_number)), pull_request: {} } };
const i = world.issues.get(issue_number);
if (!i) throw err(404);
return { data: { ...i, labels: i.labels.map((name) => ({ name })), assignees: i.assignees.map((login) => ({ login })) } };
},
listForRepo: async ({ state, creator, labels }) => ({
data: [...world.prs.values()]
.filter((p) => p.state === state && p.author === creator && p.labels.includes(labels))
.map((p) => ({ number: p.number, body: p.body, pull_request: {} })),
}),
getLabel: async () => ({ data: {} }),
createLabel: async ({ name }) => { write(`create label ${name}`); },
addLabels: async ({ issue_number, labels }) => {
const p = getPr(issue_number);
write(`label #${issue_number} ${labels}`);
for (const l of labels) if (!p.labels.includes(l)) p.labels.push(l);
},
removeLabel: async ({ issue_number, name }) => {
const p = getPr(issue_number);
write(`unlabel #${issue_number} ${name}`);
if (!p.labels.includes(name)) throw err(404);
p.labels = p.labels.filter((l) => l !== name);
},
listComments: async ({ issue_number }) => ({ data: getPr(issue_number).comments.map((c) => ({ id: c.id, body: c.body, user: { login: c.user } })) }),
createComment: async ({ issue_number, body }) => {
write(`comment on #${issue_number}`);
getPr(issue_number).comments.push({ id: world.nextCommentId++, user: 'github-actions[bot]', body });
},
updateComment: async ({ comment_id, body }) => {
write(`edit comment ${comment_id}`);
for (const p of world.prs.values()) for (const c of p.comments) if (c.id === comment_id) c.body = body;
},
deleteComment: async ({ comment_id }) => {
write(`delete comment ${comment_id}`);
for (const p of world.prs.values()) p.comments = p.comments.filter((c) => c.id !== comment_id);
},
},
};
world.github = { rest, paginate: async (fn, args) => (await fn(args)).data };
return world;
}

View file

@ -27,14 +27,14 @@ jobs:
actions: read # Required for Claude to read CI results on PRs
steps:
- name: Checkout repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 1
persist-credentials: false
- name: Run Claude Code
id: claude
uses: anthropics/claude-code-action@d5726de019ec4498aa667642bc3a80fca83aa102 # v1.0.148
uses: anthropics/claude-code-action@d40ddef4c030e508327d6e35a9c45f3368482c50 # v1.0.195
with:
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} # zizmor: ignore[secrets-outside-env]
use_commit_signing: true

View file

@ -1,229 +0,0 @@
name: Comment on PRs in Release
on:
release:
types: [published]
permissions:
pull-requests: write
contents: read
jobs:
comment-on-prs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
fetch-depth: 0
persist-credentials: false
- name: Get previous release
id: previous_release
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
env:
CURRENT_TAG: ${{ github.event.release.tag_name }}
with:
script: |
const currentTag = process.env.CURRENT_TAG;
// Paginate: with two release lines publishing interleaved, the
// previous release on this line can sit far down the list.
const releases = await github.paginate(github.rest.repos.listReleases, {
owner: context.repo.owner,
repo: context.repo.repo,
per_page: 100
});
if (!releases.some(r => r.tag_name === currentTag)) {
console.log('Current release not found in list');
return null;
}
const major = tag => (tag.match(/^v?(\d+)/) || [])[1];
if (major(currentTag) === undefined) {
console.log(`Cannot parse a major version from ${currentTag}; skipping comments`);
return null;
}
// The list is ordered by release creation date, which does not
// reliably reflect tag topology (for example, a release published
// from a long-lived draft keeps its draft creation date). Instead
// of trusting list order, compare every same-major release and
// pick the nearest ancestor of the current tag: the one the
// smallest number of commits behind it. The major check runs
// first so cross-line candidates cost no API calls; per_page=1
// because only status/ahead_by are needed here (the commits are
// fetched in the next step). For the first release of a new major
// line there is no same-line predecessor, and we skip commenting
// rather than compare across the entire new line's history.
let best = null;
for (const candidate of releases) {
if (candidate.tag_name === currentTag || candidate.draft) continue;
if (major(candidate.tag_name) !== major(currentTag)) continue;
let comparison;
try {
({ data: comparison } = await github.rest.repos.compareCommits({
owner: context.repo.owner,
repo: context.repo.repo,
base: candidate.tag_name,
head: currentTag,
per_page: 1
}));
} catch (error) {
// Tolerate only candidates whose tag no longer resolves;
// anything else (rate limits, server errors) must fail the
// job rather than silently produce a wrong comparison base.
if (error.status === 404) {
console.log(`Skipping ${candidate.tag_name}: tag does not resolve`);
continue;
}
throw error;
}
// 'identical' covers a release re-cut on the same commit; it
// yields an empty commit range downstream, hence no comments.
if (comparison.status !== 'ahead' && comparison.status !== 'identical') {
console.log(`Skipping ${candidate.tag_name}: not an ancestor of ${currentTag} (status: ${comparison.status})`);
continue;
}
if (best === null || comparison.ahead_by < best.aheadBy) {
best = { tagName: candidate.tag_name, aheadBy: comparison.ahead_by };
}
}
if (best === null) {
console.log(`No previous release found for ${currentTag} on its major line (it may be the first); skipping comments`);
return null;
}
console.log(`Found previous release: ${best.tagName} (${best.aheadBy} commits behind ${currentTag})`);
return best.tagName;
- name: Get merged PRs between releases
id: get_prs
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
env:
CURRENT_TAG: ${{ github.event.release.tag_name }}
PREVIOUS_TAG_JSON: ${{ steps.previous_release.outputs.result }}
with:
script: |
const currentTag = process.env.CURRENT_TAG;
const previousTag = JSON.parse(process.env.PREVIOUS_TAG_JSON);
if (!previousTag) {
console.log('No previous release found, skipping');
return [];
}
console.log(`Finding PRs between ${previousTag} and ${currentTag}`);
// Get commits between previous and current release. A single
// compare response caps the commit list, so paginate — but bound
// the total: a range this large means a mis-selected base, and
// commenting on hundreds of PRs is worse than commenting on none.
const MAX_COMMITS = 250;
const commits = [];
for (let page = 1; ; page++) {
const { data: comparison } = await github.rest.repos.compareCommits({
owner: context.repo.owner,
repo: context.repo.repo,
base: previousTag,
head: currentTag,
per_page: 100,
page
});
commits.push(...comparison.commits);
if (commits.length > MAX_COMMITS) {
console.log(`Range ${previousTag}...${currentTag} exceeds ${MAX_COMMITS} commits; skipping comments`);
return [];
}
if (comparison.commits.length < 100) break;
}
console.log(`Found ${commits.length} commits`);
// Get PRs associated with each commit using GitHub API
const prNumbers = new Set();
for (const commit of commits) {
try {
const { data: prs } = await github.rest.repos.listPullRequestsAssociatedWithCommit({
owner: context.repo.owner,
repo: context.repo.repo,
commit_sha: commit.sha
});
for (const pr of prs) {
if (pr.merged_at) {
prNumbers.add(pr.number);
console.log(`Found merged PR: #${pr.number}`);
}
}
} catch (error) {
console.log(`Failed to get PRs for commit ${commit.sha}: ${error.message}`);
}
}
console.log(`Found ${prNumbers.size} merged PRs`);
return Array.from(prNumbers);
- name: Comment on PRs
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
env:
PR_NUMBERS_JSON: ${{ steps.get_prs.outputs.result }}
RELEASE_TAG: ${{ github.event.release.tag_name }}
RELEASE_URL: ${{ github.event.release.html_url }}
RELEASE_IS_PRERELEASE: ${{ github.event.release.prerelease }}
with:
script: |
const prNumbers = JSON.parse(process.env.PR_NUMBERS_JSON);
const releaseTag = process.env.RELEASE_TAG;
const releaseUrl = process.env.RELEASE_URL;
// Trust the tag as well as the flag, in case the release manager
// forgets to tick the pre-release checkbox.
const isPrerelease = process.env.RELEASE_IS_PRERELEASE === 'true' || /\d(a|b|rc)\d/.test(releaseTag);
const releaseKind = isPrerelease ? 'pre-release' : 'release';
const comment = `This pull request is included in ${releaseKind} [${releaseTag}](${releaseUrl})`;
let commentedCount = 0;
for (const prNumber of prNumbers) {
try {
// Check if we've already commented on this PR for this
// release. Paginate: comments are returned oldest-first, so
// on a busy PR an earlier bot comment is exactly what would
// fall off a single page.
const comments = await github.paginate(github.rest.issues.listComments, {
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
per_page: 100
});
const alreadyCommented = comments.some(c =>
c.user.type === 'Bot' && c.body.includes(`[${releaseTag}]`)
);
if (alreadyCommented) {
console.log(`Skipping PR #${prNumber} - already commented for ${releaseTag}`);
continue;
}
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
body: comment
});
commentedCount++;
console.log(`Successfully commented on PR #${prNumber}`);
} catch (error) {
console.error(`Failed to comment on PR #${prNumber}:`, error.message);
}
}
console.log(`Commented on ${commentedCount} of ${prNumbers.length} PRs`);

View file

@ -14,53 +14,129 @@ permissions:
contents: read
env:
# Pinned conformance harness version. Bump deliberately and reconcile
# .github/actions/conformance/expected-failures.yml in the same change.
CONFORMANCE_VERSION: "0.2.0-alpha.3"
# Pinned conformance harness package spec (passed verbatim to `npx --yes`).
# Bump deliberately and reconcile the three
# .github/actions/conformance/expected-failures*.yml files in the same change.
CONFORMANCE_PKG: "@modelcontextprotocol/conformance@0.2.0-alpha.11"
# Each job runs three `--suite all` legs, one per baseline file. The two
# `--spec-version <rev>` legs run every scenario the pinned harness marks
# applicable at that spec revision, at that revision's wire (stateful
# handshake for 2025-11-25, stateless per-request _meta for 2026-07-28). Each
# is a superset of the frozen requirement set that `conformance tier-check`
# scores for that revision (`npx $CONFORMANCE_PKG list --requirements <rev>`
# prints it), so an expected-failures.<rev>.yml entry for a scenario in that
# set means tier-check reports the SDK below 100% for <rev>. The bare leg runs
# every shipped scenario at whichever wire the harness defaults it to; it is
# the only leg that reaches extension-tagged scenarios (tasks-*, auth/dpop*,
# ...), which never match a --spec-version filter. For everything else it
# overlaps one of the pinned legs, so a failure it also sees needs its own
# expected-failures.yml entry besides the pinned file's.
jobs:
server-conformance:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
- uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
with:
enable-cache: true
version: 0.9.5
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: 24
- run: uv sync --frozen --all-extras --package mcp-everything-server
- name: Run server conformance (active suite)
- name: Run server conformance (all suite, 2025-11-25 wire)
run: >-
./.github/actions/conformance/run-server.sh
--suite active
--expected-failures ./.github/actions/conformance/expected-failures.yml
- name: Run server conformance (draft suite)
--suite all
--spec-version 2025-11-25
--expected-failures ./.github/actions/conformance/expected-failures.2025-11-25.yml
--output-dir conformance-results/server-2025-11-25
- name: Run server conformance (all suite, 2026-07-28 wire)
run: >-
./.github/actions/conformance/run-server.sh
--suite draft
--suite all
--spec-version 2026-07-28
--expected-failures ./.github/actions/conformance/expected-failures.2026-07-28.yml
--output-dir conformance-results/server-2026-07-28
- name: Run server conformance (all suite, default wire)
run: >-
./.github/actions/conformance/run-server.sh
--suite all
--expected-failures ./.github/actions/conformance/expected-failures.yml
--output-dir conformance-results/server-all
- name: Upload conformance results
# The log has only summary counts; per-check data is in checks.json.
# always(), not !cancelled(): a timeout-minutes kill is a cancellation,
# and that is when the partial results matter most.
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: server-conformance-results
path: conformance-results/
if-no-files-found: ignore
client-conformance:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
- uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
with:
enable-cache: true
version: 0.9.5
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: 24
- run: uv sync --frozen --all-extras --package mcp
- name: Run client conformance (all suite)
# --compile-bytecode: without it, ~40 concurrently spawned interpreters
# race to byte-compile site-packages during the timing-sensitive window.
- run: uv sync --frozen --all-extras --package mcp --compile-bytecode
- name: Pre-compile bytecode (editable sources)
run: uv run --frozen python -m compileall -q src .github/actions/conformance
# The harness runs a leg's scenarios via unbounded Promise.all; under
# that concurrency the slowest one (sse-retry, which has a real-time SSE
# reconnect wait) needs more than the 30s default budget.
# `.venv/bin/python` (not `uv run`) avoids lockfile re-checks in ~40
# concurrent spawns; run-client.sh re-runs unexpected failures solo.
- name: Run client conformance (all suite, 2025-11-25 wire)
run: >-
npx --yes @modelcontextprotocol/conformance@"$CONFORMANCE_VERSION" client
--command 'uv run --frozen python .github/actions/conformance/client.py'
./.github/actions/conformance/run-client.sh
--command '.venv/bin/python .github/actions/conformance/client.py'
--suite all
--timeout 60000
--spec-version 2025-11-25
--expected-failures ./.github/actions/conformance/expected-failures.2025-11-25.yml
--output-dir conformance-results/client-2025-11-25
- name: Run client conformance (all suite, 2026-07-28 wire)
run: >-
./.github/actions/conformance/run-client.sh
--command '.venv/bin/python .github/actions/conformance/client.py'
--suite all
--timeout 60000
--spec-version 2026-07-28
--expected-failures ./.github/actions/conformance/expected-failures.2026-07-28.yml
--output-dir conformance-results/client-2026-07-28
- name: Run client conformance (all suite, default wire)
run: >-
./.github/actions/conformance/run-client.sh
--command '.venv/bin/python .github/actions/conformance/client.py'
--suite all
--timeout 60000
--expected-failures ./.github/actions/conformance/expected-failures.yml
--output-dir conformance-results/client-all
- name: Upload conformance results
# The log has only summary counts; per-check data is in checks.json.
# always(), not !cancelled(): a timeout-minutes kill is a cancellation,
# and that is when the partial results matter most.
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: client-conformance-results
path: conformance-results/
if-no-files-found: ignore

View file

@ -3,13 +3,22 @@ name: Deploy Docs
on:
push:
branches:
# main is the sole deployer of the combined site (v2 at / and /v2/, v1.x
# at /v1/); the v1.x branch has no deploy workflow. A v1.x docs change is
# published by the next main deploy or a manual workflow_dispatch here.
- main
- v1.x
paths:
- docs/**
# docs pages include their code blocks from these files via `--8<--`, so a
# change here changes the rendered site even when no .md file moves.
- docs_src/**
# translated pages and the language registry feed the site/<code>/ sites
- i18n/**
- mkdocs.yml
- src/mcp/**
- src/mcp-types/**
- scripts/build-docs.sh
- scripts/docs/**
- pyproject.toml
- uv.lock
- .github/workflows/deploy-docs.yml
@ -33,17 +42,17 @@ jobs:
url: ${{ steps.deployment.outputs.page_url }}
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Install uv
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
with:
enable-cache: true
version: 0.9.5
- name: Build combined docs (v1.x at /, main at /v2/)
- name: Build combined docs (main at / and /v2/, v1.x at /v1/)
run: bash scripts/build-docs.sh site
- name: Configure Pages

View file

@ -0,0 +1,44 @@
name: Docs Preview Cleanup
# Deletes Cloudflare Pages preview deployments for a PR when it closes.
# Runs as pull_request_target so secrets are available for fork PRs; it never
# checks out PR code, so there is no untrusted-code execution risk.
on:
pull_request_target: # zizmor: ignore[dangerous-triggers] never checks out PR code
types: [closed]
permissions: {}
jobs:
cleanup:
runs-on: ubuntu-latest
steps:
- name: Delete preview deployments for this PR
env:
CF_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CF_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
CF_PROJECT: ${{ vars.CLOUDFLARE_PAGES_PROJECT }}
BRANCH: pr-${{ github.event.pull_request.number }}
run: |
set -euo pipefail
if [ -z "$CF_API_TOKEN" ] || [ -z "$CF_ACCOUNT_ID" ] || [ -z "$CF_PROJECT" ]; then
echo "Cloudflare credentials/project not configured; skipping cleanup."
exit 0
fi
base="https://api.cloudflare.com/client/v4/accounts/$CF_ACCOUNT_ID/pages/projects/$CF_PROJECT/deployments"
# Collect matching ids across all pages first, then delete — deleting
# mid-pagination would shift later pages and skip entries.
ids=""
for page in $(seq 1 200); do
resp=$(curl -fsS -H "Authorization: Bearer $CF_API_TOKEN" "$base?env=preview&per_page=25&page=$page")
ids="$ids $(jq -r --arg b "$BRANCH" '.result[]? | select(.deployment_trigger.metadata.branch == $b) | .id' <<<"$resp")"
[ "$(jq '.result | length' <<<"$resp")" -lt 25 ] && break
done
deleted=0
for id in $ids; do
echo "Deleting deployment $id"
curl -fsS -X DELETE -H "Authorization: Bearer $CF_API_TOKEN" "$base/$id?force=true" > /dev/null
deleted=$((deleted + 1))
done
echo "Deleted $deleted deployment(s) for $BRANCH."

Some files were not shown because too many files have changed in this diff Show more