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
437 changed files with 10425 additions and 5009 deletions

View file

@ -20,7 +20,7 @@ Scenarios:
json-schema-ref-no-deref - Connect, list tools (no $ref deref) json-schema-ref-no-deref - Connect, list tools (no $ref deref)
json-schema-2020-12-preservation - List tools, echo the focal inputSchema back verbatim json-schema-2020-12-preservation - List tools, echo the focal inputSchema back verbatim
request-metadata - Connect with all callbacks; client stamps _meta request-metadata - Connect with all callbacks; client stamps _meta
http-standard-headers - Connect, call a tool (Mcp-* headers checked) 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-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-*) http-custom-headers - Replay the harness's toolCalls (x-mcp-header -> Mcp-Param-*)
elicitation-sep1034-client-defaults - Elicitation with default accept callback elicitation-sep1034-client-defaults - Elicitation with default accept callback
@ -311,11 +311,24 @@ async def run_request_metadata(server_url: str) -> None:
@register("http-standard-headers") @register("http-standard-headers")
async def run_http_standard_headers(server_url: str) -> None: async def run_http_standard_headers(server_url: str) -> None:
"""Connect on the modern path so Mcp-Method / Mcp-Name / MCP-Protocol-Version are sent (SEP-2243).""" """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: async with Client(server_url, mode=client_mode()) as client:
await client.list_tools() tools = await client.list_tools()
result = await client.call_tool("add_numbers", {"a": 5, "b": 3}) if tools.tools:
logger.debug(f"add_numbers result: {result}") 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]: def _stub_required_args(input_schema: dict[str, Any]) -> dict[str, Any]:

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;
}

View file

@ -78,11 +78,10 @@ module.exports = async function run({ github, context, core }) {
if (pr.merged_at) return log('merged — nothing to do'); if (pr.merged_at) return log('merged — nothing to do');
if (pr.state === 'closed' && !gated) return log('closed by someone else — not ours'); if (pr.state === 'closed' && !gated) return log('closed by someone else — not ours');
// 1. Exempt authors: bots, anyone with triage or better, and drafts (which // 1. Exempt authors: bots and anyone with triage or better. Drafts are
// are checked again on ready_for_review). // gated like any other PR.
if (pr.user.type === 'Bot') return log('author is a bot — exempt'); 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'); if (await isTrusted(pr.user.login)) return pass('author has triage+ on this repo');
if (pr.draft) return log('draft — skipped until ready for review');
// 2. Overrides: a triage+ user reopening the PR or removing the label wants // 2. Overrides: a triage+ user reopening the PR or removing the label wants
// it open. Anyone else doing so just triggers a re-check. // it open. Anyone else doing so just triggers a re-check.
@ -122,7 +121,7 @@ module.exports = async function run({ github, context, core }) {
async function fail(linkedIssues) { async function fail(linkedIssues) {
console.log(`FAIL: ${linkedIssues.length ? `not assigned to ${linkedIssues.map((n) => `#${n}`).join(', ')}` : 'no usable issue link'}`); console.log(`FAIL: ${linkedIssues.length ? `not assigned to ${linkedIssues.map((n) => `#${n}`).join(', ')}` : 'no usable issue link'}`);
await addLabel(prNumber, LABEL); await addLabel(prNumber, LABEL);
await upsertGateComment(prNumber, closedComment(linkedIssues)); await upsertGateComment(prNumber, closedComment(pr.draft, linkedIssues));
if (pr.state === 'open') { if (pr.state === 'open') {
await mutate(`close PR #${prNumber}`, () => github.rest.pulls.update({ owner, repo, pull_number: prNumber, state: 'closed' })); await mutate(`close PR #${prNumber}`, () => github.rest.pulls.update({ owner, repo, pull_number: prNumber, state: 'closed' }));
} }
@ -135,23 +134,37 @@ module.exports = async function run({ github, context, core }) {
// ── Comment text ───────────────────────────────────────────────────────── // ── Comment text ─────────────────────────────────────────────────────────
function closedComment(linkedIssues) { function closedComment(draft, linkedIssues) {
const issues = linkedIssues.map((n) => `#${n}`).join(', '); const issues = linkedIssues.map((n) => `#${n}`).join(', ');
const why = linkedIssues.length const opener = draft
? `you aren't currently assigned to ${issues}` ? "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"
: "its description doesn't yet link an open issue in this repository (with `Fixes #123` or similar)"; : 'This PR has been closed automatically. ';
const next = linkedIssues.length 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`;
? `If a maintainer would like this change as a PR from you, they'll assign you to ${issues} and this PR will reopen automatically — there's nothing more you need to do. (If you opened the issue, this PR already shows up on its timeline.)` const situation = linkedIssues.length
: `If there isn't an issue for this yet, please [open one](https://github.com/${owner}/${repo}/issues/new/choose) — a clear description of the problem is genuinely the most useful thing for us. Then add \`Fixes #<number>\` to this PR's description. If a maintainer would like the change as a PR from you, they'll assign you to the issue and this PR will reopen automatically.`; ? [
`${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 [ return [
MARKER, MARKER,
`Thanks for the contribution. This repository only keeps pull requests open when they're linked to an issue that a maintainer has assigned to the author — [CONTRIBUTING.md](${contributingUrl}) explains why and how we work. This PR has been closed for now because ${why}.`, ...situation,
'', '',
next, "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.",
'', '',
"There's no need to open a new PR — this one will be reopened. While it's closed, please push any updates as new commits rather than force-pushing, since GitHub can't reopen a PR whose branch has been rewritten.", `[CONTRIBUTING.md](${contributingUrl}) has the full reasoning, but in short:`,
'', '',
`*Maintainers: reopening this PR, removing the \`${LABEL}\` label, or adding \`${BYPASS_LABEL}\` bypasses the check.*`, "- 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'); ].join('\n');
} }

View file

@ -28,7 +28,8 @@ const PEOPLE = {
// ── Scenarios ────────────────────────────────────────────────────────────── // ── Scenarios ──────────────────────────────────────────────────────────────
// `prs` / `issues` describe the world before the event; `expect` describes each // `prs` / `issues` describe the world before the event; `expect` describes each
// PR afterwards: state, labels, and comment ('closed' = the "this PR has been // PR afterwards: state, labels, and comment ('closed' = the "this PR has been
// closed" comment, 'cannot-reopen' = the refused-reopen comment, null = none). // 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. // `writes: 0` additionally asserts the gate touched nothing at all.
const scenarios = [ const scenarios = [
@ -146,14 +147,13 @@ const scenarios = [
writes: 0, writes: 0,
}, },
{ {
name: 'draft PR is skipped until it is marked ready for review', 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 })], prs: [pr(3300, 'outsider', { draft: true })],
event: opened(3300, 'outsider'), event: opened(3300, 'outsider'),
expect: { 3300: { state: 'open', labels: [], comment: null } }, expect: { 3300: { state: 'closed', labels: [LABEL], comment: 'closed-draft' } },
writes: 0,
}, },
{ {
name: 'draft marked ready for review with no link → closed', name: 'pre-existing draft marked ready for review with no link → closed',
prs: [pr(3300, 'outsider')], prs: [pr(3300, 'outsider')],
event: readyForReview(3300, 'outsider'), event: readyForReview(3300, 'outsider'),
expect: { 3300: { state: 'closed', labels: [LABEL], comment: 'closed' } }, expect: { 3300: { state: 'closed', labels: [LABEL], comment: 'closed' } },
@ -299,7 +299,8 @@ function observe(world, expect) {
const p = world.prs.get(Number(num)); const p = world.prs.get(Number(num));
const gateComments = p.comments.filter((c) => c.user === 'github-actions[bot]' && c.body.includes('<!-- require-linked-issue -->')); 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`); assert.ok(gateComments.length <= 1, `PR #${num} has ${gateComments.length} gate comments`);
const kind = !gateComments.length ? null : gateComments[0].body.includes("won't let it be reopened") ? 'cannot-reopen' : 'closed'; 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 }; out[num] = { state: p.state, labels: [...p.labels].sort(), comment: kind };
if ('foreignComments' in expect[num]) out[num].foreignComments = p.comments.length - gateComments.length; if ('foreignComments' in expect[num]) out[num].foreignComments = p.comments.length - gateComments.length;
} }

View file

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

View file

@ -38,14 +38,14 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 15 timeout-minutes: 15
steps: steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with: with:
persist-credentials: false persist-credentials: false
- uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 - uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
with: with:
enable-cache: true enable-cache: true
version: 0.9.5 version: 0.9.5
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with: with:
node-version: 24 node-version: 24
- run: uv sync --frozen --all-extras --package mcp-everything-server - run: uv sync --frozen --all-extras --package mcp-everything-server
@ -84,14 +84,14 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 15 timeout-minutes: 15
steps: steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with: with:
persist-credentials: false persist-credentials: false
- uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 - uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
with: with:
enable-cache: true enable-cache: true
version: 0.9.5 version: 0.9.5
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with: with:
node-version: 24 node-version: 24
# --compile-bytecode: without it, ~40 concurrently spawned interpreters # --compile-bytecode: without it, ~40 concurrently spawned interpreters

View file

@ -42,12 +42,12 @@ jobs:
url: ${{ steps.deployment.outputs.page_url }} url: ${{ steps.deployment.outputs.page_url }}
steps: steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with: with:
persist-credentials: false persist-credentials: false
- name: Install uv - name: Install uv
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
with: with:
enable-cache: true enable-cache: true
version: 0.9.5 version: 0.9.5

View file

@ -4,11 +4,16 @@ name: Docs Preview
# #
# Security: the build executes Python from the PR (mkdocstrings imports # Security: the build executes Python from the PR (mkdocstrings imports
# src/mcp, `!!python/name:` config directives run, and heads may ship their # src/mcp, `!!python/name:` config directives run, and heads may ship their
# own build scripts). The build is gated by `authorize` (admin sender for # own build scripts). The build is gated by `authorize` (admin sender on a
# auto-preview, admin/maintainer commenter for /preview-docs) and isolated # same-repo branch for auto-preview, admin/maintainer commenter for
# from Cloudflare secrets — `build` runs PR code with no secrets and hands # /preview-docs) and isolated from Cloudflare secrets — `build` runs PR code
# the static site to `deploy` via an artifact, so PR code never shares a # with no secrets and hands the static site to `deploy` via an artifact, so
# runner with the Cloudflare token. # PR code never shares a runner with the Cloudflare token. Fork PRs get no
# automatic preview: actions/checkout refuses to fetch a fork's head in a
# pull_request_target run, so a maintainer requests one with /preview-docs,
# which runs under issue_comment with the same gating and isolation.
# `authorize` and `comment` run .github/scripts/docs_preview.js, checked out
# from the default branch only; those two checkouts must never take a `ref:`.
# #
# Required configuration: # Required configuration:
# - secrets.CLOUDFLARE_API_TOKEN (scope: Account → Cloudflare Pages → Edit) # - secrets.CLOUDFLARE_API_TOKEN (scope: Account → Cloudflare Pages → Edit)
@ -58,57 +63,20 @@ jobs:
head_sha: ${{ steps.check.outputs.head_sha }} head_sha: ${{ steps.check.outputs.head_sha }}
slash_attempt: ${{ steps.check.outputs.slash_attempt }} slash_attempt: ${{ steps.check.outputs.slash_attempt }}
steps: steps:
- name: Check out the scripts (default branch)
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
# No `ref:` here, ever: this job trusts what it checks out (see header).
persist-credentials: false
sparse-checkout: .github/scripts
- name: Determine authorization - name: Determine authorization
id: check id: check
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with: with:
script: | script: |
const { owner, repo } = context.repo; const { authorize } = require('./.github/scripts/docs_preview.js');
await authorize({ github, context, core });
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') {
// 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;
prNumber = String(context.payload.pull_request.number);
headSha = context.payload.pull_request.head.sha;
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));
build: build:
needs: authorize needs: authorize
@ -117,27 +85,28 @@ jobs:
permissions: permissions:
contents: read contents: read
steps: steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with: with:
ref: ${{ needs.authorize.outputs.head_sha }} ref: ${{ needs.authorize.outputs.head_sha }}
persist-credentials: false persist-credentials: false
- name: Install uv - name: Install uv
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
with: with:
# pull_request_target runs share the base-branch Actions cache; saving # Keep the untrusted build away from the shared Actions cache. GitHub
# a cache populated while untrusted PR code ran would let it poison # already limits pull_request_target and issue_comment runs to
# later trusted workflows. Mirrors publish-pypi.yml. # read-only access in the default branch's cache scope, so this is
# defence in depth (and avoids a refused save in the post step).
# Mirrors publish-pypi.yml.
enable-cache: false enable-cache: false
version: 0.9.5 version: 0.9.5
# pull_request_target runs this workflow file from the base branch, so # Both triggers run this workflow file from the default branch (whatever
# the whole recipe — dependency sync included — must come from the # the PR targets), so the whole recipe — dependency sync included — must
# checkout itself: heads that ship scripts/docs/build.sh (the Zensical # come from the checkout itself: heads that ship scripts/docs/build.sh
# toolchain) build with it; older heads, and v1.x heads previewed via # (the Zensical toolchain) build with it; older heads and v1.x heads still
# /preview-docs, still build with MkDocs. Both arms must write the site # build with MkDocs. Both arms must write the site to site/. Keep the
# to site/. Keep the detection in sync with build_site() in # detection in sync with build_site() in scripts/build-docs.sh.
# scripts/build-docs.sh.
- run: | - run: |
if [ -f scripts/docs/build.sh ]; then if [ -f scripts/docs/build.sh ]; then
bash scripts/docs/build.sh bash scripts/docs/build.sh
@ -192,10 +161,18 @@ jobs:
(needs.authorize.outputs.authorized == 'true' || needs.authorize.outputs.slash_attempt == 'true') (needs.authorize.outputs.authorized == 'true' || needs.authorize.outputs.slash_attempt == 'true')
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions: permissions:
contents: read # check out the script from the default branch
pull-requests: write pull-requests: write
steps: steps:
- name: Check out the scripts (default branch)
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
# No `ref:` here, ever: this job trusts what it checks out (see header).
persist-credentials: false
sparse-checkout: .github/scripts
- name: Post or update preview comment - name: Post or update preview comment
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
env: env:
AUTHORIZED: ${{ needs.authorize.outputs.authorized }} AUTHORIZED: ${{ needs.authorize.outputs.authorized }}
PR_NUMBER: ${{ needs.authorize.outputs.pr_number }} PR_NUMBER: ${{ needs.authorize.outputs.pr_number }}
@ -206,45 +183,5 @@ jobs:
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
with: with:
script: | script: |
const { owner, repo } = context.repo; const { comment } = require('./.github/scripts/docs_preview.js');
const env = process.env; await comment({ github, context, core });
const issue_number = Number(env.PR_NUMBER);
const marker = '<!-- docs-preview -->';
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 === 'github-actions[bot]' && 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`
);

View file

@ -13,12 +13,12 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: [checks] needs: [checks]
steps: steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with: with:
persist-credentials: false persist-credentials: false
- name: Install uv - name: Install uv
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
with: with:
enable-cache: false enable-cache: false
version: 0.12.5 version: 0.12.5

View file

@ -4,9 +4,9 @@
# In short: a PR from someone without triage rights stays open only if it links # In short: a PR from someone without triage rights stays open only if it links
# an open issue here that is assigned to them (or labeled `help wanted`); # an open issue here that is assigned to them (or labeled `help wanted`);
# otherwise it is labeled `missing-issue-link`, gets one comment, and is closed, # otherwise it is labeled `missing-issue-link`, gets one comment, and is closed,
# and it reopens automatically once the author is assigned. Bots and drafts are # and it reopens automatically once the author is assigned. Drafts are gated
# skipped. A triage+ user reopening the PR, removing the label, or adding # too; bots are skipped. A triage+ user reopening the PR, removing the label,
# `bypass-issue-check` overrides. # or adding `bypass-issue-check` overrides.
# #
# Operating it: # Operating it:
# - Live by default. To pause it without a revert, set the repository # - Live by default. To pause it without a revert, set the repository
@ -72,13 +72,13 @@ jobs:
PR_NUMBER_INPUT: ${{ inputs.pr_number }} PR_NUMBER_INPUT: ${{ inputs.pr_number }}
steps: steps:
- name: Check out the gate script (default branch) - name: Check out the gate script (default branch)
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with: with:
persist-credentials: false persist-credentials: false
sparse-checkout: .github/scripts sparse-checkout: .github/scripts
- name: Evaluate - name: Evaluate
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with: with:
script: | script: |
const run = require('./.github/scripts/pr_intake_gate.js'); const run = require('./.github/scripts/pr_intake_gate.js');

View file

@ -13,23 +13,12 @@ jobs:
pre-commit: pre-commit:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with: with:
persist-credentials: false persist-credentials: false
# setup-uv's manifest fetch is a single request with a hard 5s timeout
# (astral-sh/setup-uv#869); retry once. Drop when upstream adds a retry.
- name: Install uv - name: Install uv
id: setup-uv uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
continue-on-error: true
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
with:
enable-cache: true
version: 0.9.5
- name: Install uv (retry)
if: steps.setup-uv.outcome == 'failure'
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
with: with:
enable-cache: true enable-cache: true
version: 0.9.5 version: 0.9.5
@ -41,11 +30,11 @@ jobs:
with: with:
extra_args: --all-files --verbose extra_args: --all-files --verbose
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with: with:
node-version: 24 # match the runtime actions/github-script@v8 uses node-version: 24 # match actions/github-script's node24 runtime
- name: PR intake gate scenarios - name: Workflow script tests
run: node --test .github/scripts/pr_intake_gate.test.js run: node --test '.github/scripts/*.test.js'
- name: Surface types match vendored schema - name: Surface types match vendored schema
run: | run: |
@ -75,23 +64,12 @@ jobs:
os: [ubuntu-latest, windows-latest] os: [ubuntu-latest, windows-latest]
steps: steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with: with:
persist-credentials: false persist-credentials: false
# setup-uv's manifest fetch is a single request with a hard 5s timeout
# (astral-sh/setup-uv#869); retry once. Drop when upstream adds a retry.
- name: Install uv - name: Install uv
id: setup-uv uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
continue-on-error: true
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
with:
enable-cache: true
version: 0.9.5
- name: Install uv (retry)
if: steps.setup-uv.outcome == 'failure'
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
with: with:
enable-cache: true enable-cache: true
version: 0.9.5 version: 0.9.5
@ -121,23 +99,12 @@ jobs:
readme-snippets: readme-snippets:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with: with:
persist-credentials: false persist-credentials: false
# setup-uv's manifest fetch is a single request with a hard 5s timeout
# (astral-sh/setup-uv#869); retry once. Drop when upstream adds a retry.
- name: Install uv - name: Install uv
id: setup-uv uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
continue-on-error: true
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
with:
enable-cache: true
version: 0.9.5
- name: Install uv (retry)
if: steps.setup-uv.outcome == 'failure'
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
with: with:
enable-cache: true enable-cache: true
version: 0.9.5 version: 0.9.5
@ -161,23 +128,12 @@ jobs:
docs: docs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with: with:
persist-credentials: false persist-credentials: false
# setup-uv's manifest fetch is a single request with a hard 5s timeout
# (astral-sh/setup-uv#869); retry once. Drop when upstream adds a retry.
- name: Install uv - name: Install uv
id: setup-uv uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
continue-on-error: true
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
with:
enable-cache: true
version: 0.9.5
- name: Install uv (retry)
if: steps.setup-uv.outcome == 'failure'
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
with: with:
enable-cache: true enable-cache: true
version: 0.9.5 version: 0.9.5

View file

@ -17,9 +17,9 @@ jobs:
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with: with:
persist-credentials: false persist-credentials: false
- name: Run zizmor 🌈 - name: Run zizmor 🌈
uses: zizmorcore/zizmor-action@5f14fd08f7cf1cb1609c1e344975f152c7ee938d # v0.5.6 uses: zizmorcore/zizmor-action@3dc1ecc9bcb9e94e9b2c709687979e1298497054 # v0.6.2

View file

@ -34,7 +34,7 @@ Whether to assign an issue, and to whom, is a [maintainer](https://github.com/mo
We use AI tooling constantly and have no problem with you using it too. What matters is that a person is accountable for the result: We use AI tooling constantly and have no problem with you using it too. What matters is that a person is accountable for the result:
- **Disclose it.** One line in the PR or issue description. - **Disclose it.** One line in the PR description, issue, or comment.
- **Own it.** You can explain the change and the reasoning in your own words, and when a maintainer asks a question the answer comes from you rather than being pasted from a chat window. - **Own it.** You can explain the change and the reasoning in your own words, and when a maintainer asks a question the answer comes from you rather than being pasted from a chat window.
- **Keep a human in the loop.** Issues, PRs, and comments generated by an agent without a person who has actually hit the problem and read the output may be closed without warning. If you have an agent filing PRs against open issues autonomously, please turn it off for this repository. - **Keep a human in the loop.** Issues, PRs, and comments generated by an agent without a person who has actually hit the problem and read the output may be closed without warning. If you have an agent filing PRs against open issues autonomously, please turn it off for this repository.
- **Keep issues short and factual.** What happened, what you expected, and how to reproduce it. - **Keep issues short and factual.** What happened, what you expected, and how to reproduce it.

View file

@ -20,7 +20,7 @@ then come back.
## A clock with a face ## A clock with a face
```python title="server.py" hl_lines="19 22 30 32" ```python title="server.py" hl_lines="17 20 28 30"
--8<-- "docs_src/apps/tutorial001.py" --8<-- "docs_src/apps/tutorial001.py"
``` ```
@ -51,15 +51,36 @@ The model reads `content`; the iframe is for humans. A UI-capable host still fee
the text result to the model, and a text-only client gets *only* that. So the the text result to the model, and a text-only client gets *only* that. So the
canonical pattern is one tool, two answers. Look at `get_time` again: canonical pattern is one tool, two answers. Look at `get_time` again:
```python title="server.py" hl_lines="23-27" ```python title="server.py" hl_lines="21-25"
--8<-- "docs_src/apps/tutorial001.py" --8<-- "docs_src/apps/tutorial001.py"
``` ```
`client_supports_apps(ctx)` is `True` only when the client declared the `client_supports_apps(ctx)` is `True` only when the client declared the
`io.modelcontextprotocol/ui` extension **and** listed `text/html;profile=mcp-app` `io.modelcontextprotocol/ui` extension **and** listed `text/html;profile=mcp-app`
in its `mimeTypes` settings. The field is required, so a client that omits it in its `mimeTypes` settings. The field is required, so a client that omits it
does not count. That is exactly what `main()` in the same file declares: the does not count. Here is the client half of the negotiation:
client half of the negotiation, and the rich answer comes back.
```python title="client.py" hl_lines="8 12"
--8<-- "docs_src/apps/tutorial001_client.py"
```
Serve `server.py` over HTTP, then run the client from a second terminal:
```console
uv run mcp run server.py --transport streamable-http
```
```console
python client.py
```
```text
2026-06-26T12:00:00Z
```
The rich answer came back. Drop `extensions=[APPS_SUPPORT]` from the `Client` call
and the same program prints `The time is 2026-06-26T12:00:00Z.` instead, which is
all a text-only client ever sees.
!!! warning !!! warning
Never return a placeholder like `"[Rendered UI]"` as the only content. If the Never return a placeholder like `"[Rendered UI]"` as the only content. If the

View file

@ -57,7 +57,7 @@ specified by the MCP project itself.
The smallest useful extension is one tool and a settings map: The smallest useful extension is one tool and a settings map:
```python title="server.py" hl_lines="17 19-20 22-23 26" ```python title="server.py" hl_lines="16 18-19 21-22 25"
--8<-- "docs_src/extensions/tutorial003.py" --8<-- "docs_src/extensions/tutorial003.py"
``` ```
@ -69,18 +69,25 @@ The smallest useful extension is one tool and a settings map:
* The extension never receives the server. It declares contributions as data; * The extension never receives the server. It declares contributions as data;
`MCPServer` consumes them. There is no `self.server` to mutate. `MCPServer` consumes them. There is no `self.server` to mutate.
And `main()` is the proof, an in-memory client straight against `mcp`: Serve it over HTTP, and a client is the proof:
```python title="server.py" hl_lines="29-34" ```console
--8<-- "docs_src/extensions/tutorial003.py" uv run mcp run server.py --transport streamable-http
``` ```
```python title="client.py" hl_lines="7-11"
--8<-- "docs_src/extensions/tutorial003_client.py"
```
Every `server.py` on this page is served with that command, and every `client.py`
runs beside it with `python client.py` from a second terminal.
### Serving your own methods ### Serving your own methods
An extension can register **new request methods**: its own verbs, served next to the An extension can register **new request methods**: its own verbs, served next to the
spec's: spec's:
```python title="server.py" hl_lines="16-22 31 40-48" ```python title="server.py" hl_lines="14-20 24 33-41"
--8<-- "docs_src/extensions/tutorial004.py" --8<-- "docs_src/extensions/tutorial004.py"
``` ```
@ -107,10 +114,10 @@ runtime:
### The client side ### The client side
The same file's `main()` is the whole client story, both halves of it: The client is its own program, and it carries both halves of the client story:
```python title="server.py" hl_lines="54-58" ```python title="client.py" hl_lines="21-23 27-30"
--8<-- "docs_src/extensions/tutorial004.py" --8<-- "docs_src/extensions/tutorial004_client.py"
``` ```
* `Client(..., extensions=[advertise(EXTENSION_ID)])` declares the extension. The * `Client(..., extensions=[advertise(EXTENSION_ID)])` declares the extension. The
@ -122,6 +129,9 @@ The same file's `main()` is the whole client story, both halves of it:
* Vendor methods drop one layer to `client.session.send_request(...)`; `Client` * Vendor methods drop one layer to `client.session.send_request(...)`; `Client`
only grows first-class methods for spec verbs. `send_request` accepts any only grows first-class methods for spec verbs. `send_request` accepts any
`Request` subclass, so the vendor request passes as-is. `Request` subclass, so the vendor request passes as-is.
* `SearchRequest` and the two models it carries are the extension's wire contract,
so the client declares them for itself. A published extension would ship them in
a package that both sides import.
### Intercepting `tools/call` ### Intercepting `tools/call`
@ -155,13 +165,20 @@ The hook wraps `tools/call` and nothing else. For every-message concerns, use
## Using a client extension ## Using a client extension
A **client extension** is the same contract from the consuming side: a bundle of A **client extension** is the same contract from the consuming side: a bundle of
client-side behaviour behind one identifier. Pass instances to client-side behaviour behind one identifier. The server here answers `buy` with a
`Client(extensions=[...])` and call tools normally: receipt to redeem instead of the goods, and only for a client that declared the
extension:
```python title="client.py" hl_lines="66-68" ```python title="server.py" hl_lines="22-25"
--8<-- "docs_src/extensions/tutorial006.py" --8<-- "docs_src/extensions/tutorial006.py"
``` ```
On the client, pass instances to `Client(extensions=[...])` and call tools normally:
```python title="client.py" hl_lines="33-35"
--8<-- "docs_src/extensions/tutorial006_client.py"
```
`call_tool("buy", ...)` returns a plain `CallToolResult`, like every other call. What `call_tool("buy", ...)` returns a plain `CallToolResult`, like every other call. What
the extension changed: the server may now answer `buy` with a `receipt` **result the extension changed: the server may now answer `buy` with a `receipt` **result
shape** instead of a final result, and `Receipts` finishes it (here by redeeming the shape** instead of a final result, and `Receipts` finishes it (here by redeeming the
@ -180,7 +197,7 @@ the capability, the client does nothing, as in the search client above), use
```python ```python
from mcp.client import advertise from mcp.client import advertise
client = Client(mcp, extensions=[advertise("com.example/search")]) client = Client("http://localhost:8000/mcp", extensions=[advertise("com.example/search")])
``` ```
## Writing a client extension ## Writing a client extension
@ -188,8 +205,8 @@ client = Client(mcp, extensions=[advertise("com.example/search")])
Subclass `ClientExtension` and override only what you need. Three contribution Subclass `ClientExtension` and override only what you need. Three contribution
kinds, each with a default: `settings()`, `claims()`, and `notifications()`. kinds, each with a default: `settings()`, `claims()`, and `notifications()`.
```python title="client.py" hl_lines="17-18 43-44 46-47" ```python title="client.py" hl_lines="16-17 25-26 28-29"
--8<-- "docs_src/extensions/tutorial006.py" --8<-- "docs_src/extensions/tutorial006_client.py"
``` ```
* The identifier follows the same grammar as the server's, validated when the class * The identifier follows the same grammar as the server's, validated when the class
@ -227,14 +244,21 @@ claimed shape reaching a session-tier caller raises `UnexpectedClaimedResult`.
An extension's own request methods need no client-side registration. A vendor request An extension's own request methods need no client-side registration. A vendor request
type subclasses `mcp.types.Request` and goes through `client.session.send_request`, type subclasses `mcp.types.Request` and goes through `client.session.send_request`,
as in [Serving your own methods](#serving-your-own-methods). One addition: when a as in [Serving your own methods](#serving-your-own-methods). Take a server whose
params key must ride the `Mcp-Name` header (extension specs such as tasks require extension serves one verb about a named job:
this for their verbs), the request type declares `name_param`:
```python title="client.py" hl_lines="22-25 46-47" ```python title="server.py" hl_lines="12-13 30"
--8<-- "docs_src/extensions/tutorial007.py" --8<-- "docs_src/extensions/tutorial007.py"
``` ```
One addition on the client: when a params key must ride the `Mcp-Name` header
(extension specs such as tasks require this for their verbs), the request type
declares `name_param`:
```python title="client.py" hl_lines="20-23 28-29"
--8<-- "docs_src/extensions/tutorial007_client.py"
```
The session mirrors `params["jobId"]` into `Mcp-Name` on every send path, and a The session mirrors `params["jobId"]` into `Mcp-Name` on every send path, and a
missing value fails loudly rather than silently omitting a required header. missing value fails loudly rather than silently omitting a required header.

View file

@ -31,18 +31,22 @@ Three things changed, and they are the whole low-level API:
### Try it ### Try it
There is no Inspector for this one: `mcp dev` and `mcp run` only accept an `MCPServer`. The in-memory `Client` doesn't care; it takes a low-level `Server` exactly like it takes an `MCPServer`: `mcp dev` and `mcp run` only accept an `MCPServer`, so you serve this one yourself. The last line of `server.py` builds an ordinary ASGI app from it, and uvicorn runs that:
```python title="main.py" ```console
uvicorn server:app --port 8000
```
Point the Inspector, or any client, at `http://localhost:8000/mcp`:
```python title="client.py"
import asyncio import asyncio
from mcp import Client from mcp import Client
from server import server
async def main() -> None: async def main() -> None:
async with Client(server) as client: async with Client("http://localhost:8000/mcp") as client:
result = await client.call_tool("search_books", {"query": "dune", "limit": 5}) result = await client.call_tool("search_books", {"query": "dune", "limit": 5})
print(result.content) print(result.content)
@ -59,6 +63,8 @@ The same text the `@mcp.tool()` version produced. Two honest differences:
* `result.structured_content` is `None`. The high-level server wraps a `-> str` into `{"result": ...}` for you; here nobody builds what you didn't build. * `result.structured_content` is `None`. The high-level server wraps a `-> str` into `{"result": ...}` for you; here nobody builds what you didn't build.
* `list_tools` returns the schema **you** typed, character for character. The high-level version had `"title": "Query"` on every property and a `"title": "search_booksArguments"` at the root: Pydantic artifacts. Down here, if it's on the wire, you put it there. * `list_tools` returns the schema **you** typed, character for character. The high-level version had `"title": "Query"` on every property and a `"title": "search_booksArguments"` at the root: Pydantic artifacts. Down here, if it's on the wire, you put it there.
In a test you skip uvicorn and the port: `Client(server)` takes a low-level `Server` in-process exactly like it takes an `MCPServer`, and **[Testing](../get-started/testing.md)** is that pattern.
## Nothing is checked for you ## Nothing is checked for you
`MCPServer` rejects a bad argument before your function ever runs, validating the call against the schema it generated (**[Tools](../servers/tools.md)**). `MCPServer` rejects a bad argument before your function ever runs, validating the call against the schema it generated (**[Tools](../servers/tools.md)**).
@ -210,4 +216,4 @@ Each of these is one idea you now have the vocabulary for; each has its own page
* `add_request_handler(method, params_type, handler)` serves any method. `initialize` is reserved. * `add_request_handler(method, params_type, handler)` serves any method. `initialize` is reserved.
* The capabilities a `Server` advertises are derived from which handlers you registered. * The capabilities a `Server` advertises are derived from which handlers you registered.
`Client(server)` treated both servers identically because they *are* the same protocol, which is the whole point. The next layer down isn't a class at all: it's **[Middleware](middleware.md)**. The client treated both servers identically because they *are* the same protocol, which is the whole point. The next layer down isn't a class at all: it's **[Middleware](middleware.md)**.

View file

@ -26,9 +26,13 @@ Pagination is for the server whose resource list is really a database: thousands
### Try it ### Try it
`Client(server)` connects to a low-level `Server` in memory exactly as it connects to an `MCPServer`. `mcp run` only accepts an `MCPServer`, so you serve this one yourself. The last line of `server.py` builds an ordinary ASGI app from the `Server`, and uvicorn runs that:
Call `list_resources()` with no arguments. You get ten resources, `book-1` through `book-10`, and `next_cursor` is the string `"10"`. ```console
uvicorn server:app --port 8000
```
Point any client (**[The Client](../client/index.md)**, or the Inspector) at `http://localhost:8000/mcp` and call `list_resources()` with no arguments. You get ten resources, `book-1` through `book-10`, and `next_cursor` is the string `"10"`.
Hand it back with `list_resources(cursor="10")` and the first resource is `book-11`, the new `next_cursor` is `"20"`. Hand it back with `list_resources(cursor="10")` and the first resource is `book-11`, the new `next_cursor` is `"20"`.
@ -38,7 +42,7 @@ The tenth page comes back with `next_cursor` set to `None`. Done.
Every `list_*` method on `Client` (`list_tools`, `list_resources`, `list_resource_templates`, `list_prompts`) takes a `cursor=` keyword. Draining a paged list is one `while True`: Every `list_*` method on `Client` (`list_tools`, `list_resources`, `list_resource_templates`, `list_prompts`) takes a `cursor=` keyword. Draining a paged list is one `while True`:
```python title="client.py" hl_lines="26-32" ```python title="client.py" hl_lines="9-15"
--8<-- "docs_src/pagination/tutorial002.py" --8<-- "docs_src/pagination/tutorial002.py"
``` ```
@ -46,7 +50,7 @@ Every `list_*` method on `Client` (`list_tools`, `list_resources`, `list_resourc
* Extend **before** you look at `next_cursor`: the last page has resources too. * Extend **before** you look at `next_cursor`: the last page has resources too.
* `next_cursor is None` is the exit. Anything else goes straight back into `cursor=`, untouched. * `next_cursor is None` is the exit. Anything else goes straight back into `cursor=`, untouched.
Run its `main()` and it prints `100 resources`: ten pages of ten, stitched together by a loop that never knew there were ten pages. With uvicorn still serving `server.py`, run `python client.py` in a second terminal. It prints `100 resources`: ten pages of ten, stitched together by a loop that never knew there were ten pages.
This is the same loop **[The Client](../client/index.md)** shows for every `list_*` verb, and it costs nothing against a server that doesn't page: `next_cursor` is `None` on the first response and the loop runs once. This is the same loop **[The Client](../client/index.md)** shows for every `list_*` verb, and it costs nothing against a server that doesn't page: `next_cursor` is `None` on the first response and the loop runs once.

View file

@ -25,7 +25,7 @@ Out of the box every result says `ttlMs: 0, cacheScope: "private"`: immediately
On the low-level `Server`, handlers build their results by hand, and `ttl_ms` / `cache_scope` are just fields on the result models. A handler that sets them explicitly always wins over the constructor map, field by field: On the low-level `Server`, handlers build their results by hand, and `ttl_ms` / `cache_scope` are just fields on the result models. A handler that sets them explicitly always wins over the constructor map, field by field:
```python title="server.py" hl_lines="10 16" ```python title="server.py" hl_lines="11 17"
--8<-- "docs_src/caching/tutorial002.py" --8<-- "docs_src/caching/tutorial002.py"
``` ```
@ -39,10 +39,24 @@ One caveat on paginated lists: the protocol requires the **same `cacheScope` on
On a 2026-07-28 session, `Client` honors the hints for you: it has a built-in response cache, on by default. A result that arrives carrying a `ttlMs` is stored, and an identical call within that TTL is served from the cache with no round trip. A result that carries *no* hint is not cached: hint-less results get `CacheConfig.default_ttl_ms`, which defaults to `0` (immediately stale), so a server that declares nothing sees exactly the call-for-call traffic it always did. On a 2026-07-28 session, `Client` honors the hints for you: it has a built-in response cache, on by default. A result that arrives carrying a `ttlMs` is stored, and an identical call within that TTL is served from the cache with no round trip. A result that carries *no* hint is not cached: hint-less results get `CacheConfig.default_ttl_ms`, which defaults to `0` (immediately stale), so a server that declares nothing sees exactly the call-for-call traffic it always did.
```python title="client.py" hl_lines="33 35 38" To watch that happen, serve the `server.py` from the previous section with uvicorn (its last line builds the ASGI app). The handler prints a line every time it actually runs:
```console
uvicorn server:app --port 8000
```
```python title="client.py" hl_lines="20 23 28"
--8<-- "docs_src/caching/tutorial003.py" --8<-- "docs_src/caching/tutorial003.py"
``` ```
Run `python client.py` from a second terminal. It prints the hints the first result carried, the handler's `ttlMs` next to the map's `cacheScope`:
```text
1000 public
```
The server's terminal tells the rest of the story: between uvicorn's request logs, `tools/list served` appears three times.
Four calls, three fetches. The second call found a fresh entry and never reached the server; advancing the (injected) clock past the TTL made the third fetch again; the fourth said `cache_mode="refresh"`. That kwarg exists on the five caching verbs (`list_tools`, `list_prompts`, `list_resources`, `list_resource_templates`, `read_resource`): Four calls, three fetches. The second call found a fresh entry and never reached the server; advancing the (injected) clock past the TTL made the third fetch again; the fourth said `cache_mode="refresh"`. That kwarg exists on the five caching verbs (`list_tools`, `list_prompts`, `list_resources`, `list_resource_templates`, `read_resource`):
* `"use"` (the default) serves a fresh entry if there is one, and stores the fetch if not. * `"use"` (the default) serves a fresh entry if there is one, and stores the fetch if not.
@ -51,7 +65,7 @@ Four calls, three fetches. The second call found a fresh entry and never reached
One rule sits above `"use"`: **calls carrying `meta` always reach the server.** A request with `meta` set (a progress token, tracing fields) expects a wire request, so under `cache_mode="use"` it is treated as `"refresh"`: the cache read is skipped, and the fetched result still replaces the cached entry. `"bypass"` and an explicit `"refresh"` behave as they always do. One rule sits above `"use"`: **calls carrying `meta` always reach the server.** A request with `meta` set (a progress token, tracing fields) expects a wire request, so under `cache_mode="use"` it is treated as `"refresh"`: the cache read is skipped, and the fetched result still replaces the cached entry. `"bypass"` and an explicit `"refresh"` behave as they always do.
To turn caching off entirely, construct with `Client(server, cache=None)`: every call is a round trip again, and `cache_mode`, while still accepted, does nothing. To turn caching off entirely, pass `cache=None` when constructing the `Client`: every call is a round trip again, and `cache_mode`, while still accepted, does nothing.
Scope is honored automatically too: `"private"` entries are keyed to the cache's *partition* (below), while `"public"` ones may opt into wider sharing. And **notifications beat TTL** for the exact entries they name: a `list_changed` notification evicts the matching cached listing, and `resources/updated` evicts the cached read stored under exactly its URI, however fresh they were. On a 2026-07-28 connection those notifications arrive on a `subscriptions/listen` stream you open with `client.listen(...)`, and eviction completes before your watcher sees the event; **[Subscriptions](subscriptions.md)** is that page. Scope is honored automatically too: `"private"` entries are keyed to the cache's *partition* (below), while `"public"` ones may opt into wider sharing. And **notifications beat TTL** for the exact entries they name: a `list_changed` notification evicts the matching cached listing, and `resources/updated` evicts the cached read stored under exactly its URI, however fresh they were. On a 2026-07-28 connection those notifications arrive on a `subscriptions/listen` stream you open with `client.listen(...)`, and eviction completes before your watcher sees the event; **[Subscriptions](subscriptions.md)** is that page.

View file

@ -58,7 +58,7 @@ One `tools/call` from you, one `elicitation/create` back from the server, answer
`mode="legacy"` on the `Client(...)` call is doing real work. By default `Client(...)` negotiates the modern `mode="legacy"` on the `Client(...)` call is doing real work. By default `Client(...)` negotiates the modern
protocol path, and that path has no back-channel for server-to-client requests: `ctx.elicit` protocol path, and that path has no back-channel for server-to-client requests: `ctx.elicit`
fails before your callback ever runs. The transport doesn't decide that; the negotiated fails before your callback ever runs. The transport doesn't decide that; the negotiated
protocol does, in-memory and over a URL alike. Pin `mode="legacy"` whenever your client has protocol does. Pin `mode="legacy"` whenever your client has
to answer one; every test behind this page does. **[Protocol versions](../protocol-versions.md)** has the whole story. to answer one; every test behind this page does. **[Protocol versions](../protocol-versions.md)** has the whole story.
On a 2026-07-28 session the callback isn't dead, it's fed differently: when a tool returns an On a 2026-07-28 session the callback isn't dead, it's fed differently: when a tool returns an
@ -146,4 +146,4 @@ Two more. Neither declares anything.
* `sampling_callback` and `list_roots_callback` work the same way but serve deprecated features; modern servers use multi-round-trip requests instead. * `sampling_callback` and `list_roots_callback` work the same way but serve deprecated features; modern servers use multi-round-trip requests instead.
* `logging_callback` and `message_handler` receive notifications. They declare nothing. * `logging_callback` and `message_handler` receive notifications. They declare nothing.
The first argument to `Client(...)` is a transport object. **[Client transports](transports.md)** covers every kind. The first argument to `Client(...)` picks the transport. **[Client transports](transports.md)** covers every kind.

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