Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6affe5c0d3 |
||
|
|
9972c21aa4 |
||
|
|
fd66270926 |
||
|
|
08a3bc8eaf |
||
|
|
7bb486a10f |
||
|
|
0c9136841f |
||
|
|
9771e6b59c |
||
|
|
a925e55893 |
||
|
|
e8b94867ac |
||
|
|
c6762e821c |
||
|
|
5fd3abc548 |
||
|
|
a2dab7e2dd |
||
|
|
2e0a18199a |
||
|
|
10dc173637 |
||
|
|
979208c115 |
||
|
|
d060b36e1d |
||
|
|
d2290ca343 |
||
|
|
6705402e24 |
||
|
|
e7284ed130 |
437 changed files with 10425 additions and 5009 deletions
23
.github/actions/conformance/client.py
vendored
23
.github/actions/conformance/client.py
vendored
|
|
@ -20,7 +20,7 @@ Scenarios:
|
|||
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 - 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-custom-headers - Replay the harness's toolCalls (x-mcp-header -> Mcp-Param-*)
|
||||
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")
|
||||
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:
|
||||
await client.list_tools()
|
||||
result = await client.call_tool("add_numbers", {"a": 5, "b": 3})
|
||||
logger.debug(f"add_numbers result: {result}")
|
||||
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]:
|
||||
|
|
|
|||
117
.github/scripts/docs_preview.js
vendored
Normal file
117
.github/scripts/docs_preview.js
vendored
Normal 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
241
.github/scripts/docs_preview.test.js
vendored
Normal 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;
|
||||
}
|
||||
43
.github/scripts/pr_intake_gate.js
vendored
43
.github/scripts/pr_intake_gate.js
vendored
|
|
@ -78,11 +78,10 @@ module.exports = async function run({ github, context, core }) {
|
|||
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, anyone with triage or better, and drafts (which
|
||||
// are checked again on ready_for_review).
|
||||
// 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');
|
||||
if (pr.draft) return log('draft — skipped until ready for review');
|
||||
|
||||
// 2. Overrides: a triage+ user reopening the PR or removing the label wants
|
||||
// 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) {
|
||||
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(linkedIssues));
|
||||
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' }));
|
||||
}
|
||||
|
|
@ -135,23 +134,37 @@ module.exports = async function run({ github, context, core }) {
|
|||
|
||||
// ── Comment text ─────────────────────────────────────────────────────────
|
||||
|
||||
function closedComment(linkedIssues) {
|
||||
function closedComment(draft, linkedIssues) {
|
||||
const issues = linkedIssues.map((n) => `#${n}`).join(', ');
|
||||
const why = linkedIssues.length
|
||||
? `you aren't currently assigned to ${issues}`
|
||||
: "its description doesn't yet link an open issue in this repository (with `Fixes #123` or similar)";
|
||||
const next = linkedIssues.length
|
||||
? `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.)`
|
||||
: `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.`;
|
||||
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,
|
||||
`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');
|
||||
}
|
||||
|
||||
|
|
|
|||
13
.github/scripts/pr_intake_gate.test.js
vendored
13
.github/scripts/pr_intake_gate.test.js
vendored
|
|
@ -28,7 +28,8 @@ const PEOPLE = {
|
|||
// ── 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, '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.
|
||||
|
||||
const scenarios = [
|
||||
|
|
@ -146,14 +147,13 @@ const scenarios = [
|
|||
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 })],
|
||||
event: opened(3300, 'outsider'),
|
||||
expect: { 3300: { state: 'open', labels: [], comment: null } },
|
||||
writes: 0,
|
||||
expect: { 3300: { state: 'closed', labels: [LABEL], comment: 'closed-draft' } },
|
||||
},
|
||||
{
|
||||
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')],
|
||||
event: readyForReview(3300, 'outsider'),
|
||||
expect: { 3300: { state: 'closed', labels: [LABEL], comment: 'closed' } },
|
||||
|
|
@ -299,7 +299,8 @@ function observe(world, 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 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 };
|
||||
if ('foreignComments' in expect[num]) out[num].foreignComments = p.comments.length - gateComments.length;
|
||||
}
|
||||
|
|
|
|||
4
.github/workflows/claude.yml
vendored
4
.github/workflows/claude.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
12
.github/workflows/conformance.yml
vendored
12
.github/workflows/conformance.yml
vendored
|
|
@ -38,14 +38,14 @@ jobs:
|
|||
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
|
||||
|
|
@ -84,14 +84,14 @@ jobs:
|
|||
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
|
||||
# --compile-bytecode: without it, ~40 concurrently spawned interpreters
|
||||
|
|
|
|||
4
.github/workflows/deploy-docs.yml
vendored
4
.github/workflows/deploy-docs.yml
vendored
|
|
@ -42,12 +42,12 @@ 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
|
||||
|
|
|
|||
151
.github/workflows/docs-preview.yml
vendored
151
.github/workflows/docs-preview.yml
vendored
|
|
@ -4,11 +4,16 @@ name: Docs Preview
|
|||
#
|
||||
# Security: the build executes Python from the PR (mkdocstrings imports
|
||||
# src/mcp, `!!python/name:` config directives run, and heads may ship their
|
||||
# own build scripts). The build is gated by `authorize` (admin sender for
|
||||
# auto-preview, admin/maintainer commenter for /preview-docs) and isolated
|
||||
# from Cloudflare secrets — `build` runs PR code with no secrets and hands
|
||||
# the static site to `deploy` via an artifact, so PR code never shares a
|
||||
# runner with the Cloudflare token.
|
||||
# own build scripts). The build is gated by `authorize` (admin sender on a
|
||||
# same-repo branch for auto-preview, admin/maintainer commenter for
|
||||
# /preview-docs) and isolated from Cloudflare secrets — `build` runs PR code
|
||||
# with no secrets and hands the static site to `deploy` via an artifact, so
|
||||
# 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:
|
||||
# - secrets.CLOUDFLARE_API_TOKEN (scope: Account → Cloudflare Pages → Edit)
|
||||
|
|
@ -58,57 +63,20 @@ jobs:
|
|||
head_sha: ${{ steps.check.outputs.head_sha }}
|
||||
slash_attempt: ${{ steps.check.outputs.slash_attempt }}
|
||||
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
|
||||
id: check
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
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') {
|
||||
// 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));
|
||||
const { authorize } = require('./.github/scripts/docs_preview.js');
|
||||
await authorize({ github, context, core });
|
||||
|
||||
build:
|
||||
needs: authorize
|
||||
|
|
@ -117,27 +85,28 @@ jobs:
|
|||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ needs.authorize.outputs.head_sha }}
|
||||
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:
|
||||
# pull_request_target runs share the base-branch Actions cache; saving
|
||||
# a cache populated while untrusted PR code ran would let it poison
|
||||
# later trusted workflows. Mirrors publish-pypi.yml.
|
||||
# Keep the untrusted build away from the shared Actions cache. GitHub
|
||||
# already limits pull_request_target and issue_comment runs to
|
||||
# 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
|
||||
version: 0.9.5
|
||||
|
||||
# pull_request_target runs this workflow file from the base branch, so
|
||||
# the whole recipe — dependency sync included — must come from the
|
||||
# checkout itself: heads that ship scripts/docs/build.sh (the Zensical
|
||||
# toolchain) build with it; older heads, and v1.x heads previewed via
|
||||
# /preview-docs, still build with MkDocs. Both arms must write the site
|
||||
# to site/. Keep the detection in sync with build_site() in
|
||||
# scripts/build-docs.sh.
|
||||
# Both triggers run this workflow file from the default branch (whatever
|
||||
# the PR targets), so the whole recipe — dependency sync included — must
|
||||
# come from the checkout itself: heads that ship scripts/docs/build.sh
|
||||
# (the Zensical toolchain) build with it; older heads and v1.x heads still
|
||||
# build with MkDocs. Both arms must write the site to site/. Keep the
|
||||
# detection in sync with build_site() in scripts/build-docs.sh.
|
||||
- run: |
|
||||
if [ -f scripts/docs/build.sh ]; then
|
||||
bash scripts/docs/build.sh
|
||||
|
|
@ -192,10 +161,18 @@ jobs:
|
|||
(needs.authorize.outputs.authorized == 'true' || needs.authorize.outputs.slash_attempt == 'true')
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read # check out the script from the default branch
|
||||
pull-requests: write
|
||||
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
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
env:
|
||||
AUTHORIZED: ${{ needs.authorize.outputs.authorized }}
|
||||
PR_NUMBER: ${{ needs.authorize.outputs.pr_number }}
|
||||
|
|
@ -206,45 +183,5 @@ jobs:
|
|||
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
with:
|
||||
script: |
|
||||
const { owner, repo } = context.repo;
|
||||
const env = process.env;
|
||||
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`
|
||||
);
|
||||
const { comment } = require('./.github/scripts/docs_preview.js');
|
||||
await comment({ github, context, core });
|
||||
|
|
|
|||
4
.github/workflows/publish-pypi.yml
vendored
4
.github/workflows/publish-pypi.yml
vendored
|
|
@ -13,12 +13,12 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
needs: [checks]
|
||||
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: false
|
||||
version: 0.12.5
|
||||
|
|
|
|||
10
.github/workflows/require-linked-issue.yml
vendored
10
.github/workflows/require-linked-issue.yml
vendored
|
|
@ -4,9 +4,9 @@
|
|||
# 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`);
|
||||
# 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
|
||||
# skipped. A triage+ user reopening the PR, removing the label, or adding
|
||||
# `bypass-issue-check` overrides.
|
||||
# and it reopens automatically once the author is assigned. Drafts are gated
|
||||
# too; bots are skipped. A triage+ user reopening the PR, removing the label,
|
||||
# or adding `bypass-issue-check` overrides.
|
||||
#
|
||||
# Operating it:
|
||||
# - Live by default. To pause it without a revert, set the repository
|
||||
|
|
@ -72,13 +72,13 @@ jobs:
|
|||
PR_NUMBER_INPUT: ${{ inputs.pr_number }}
|
||||
steps:
|
||||
- name: Check out the gate script (default branch)
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
sparse-checkout: .github/scripts
|
||||
|
||||
- name: Evaluate
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
const run = require('./.github/scripts/pr_intake_gate.js');
|
||||
|
|
|
|||
68
.github/workflows/shared.yml
vendored
68
.github/workflows/shared.yml
vendored
|
|
@ -13,23 +13,12 @@ jobs:
|
|||
pre-commit:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
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
|
||||
id: setup-uv
|
||||
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
|
||||
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
|
||||
with:
|
||||
enable-cache: true
|
||||
version: 0.9.5
|
||||
|
|
@ -41,11 +30,11 @@ jobs:
|
|||
with:
|
||||
extra_args: --all-files --verbose
|
||||
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: 24 # match the runtime actions/github-script@v8 uses
|
||||
- name: PR intake gate scenarios
|
||||
run: node --test .github/scripts/pr_intake_gate.test.js
|
||||
node-version: 24 # match actions/github-script's node24 runtime
|
||||
- name: Workflow script tests
|
||||
run: node --test '.github/scripts/*.test.js'
|
||||
|
||||
- name: Surface types match vendored schema
|
||||
run: |
|
||||
|
|
@ -75,23 +64,12 @@ jobs:
|
|||
os: [ubuntu-latest, windows-latest]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
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
|
||||
id: setup-uv
|
||||
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
|
||||
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
|
||||
with:
|
||||
enable-cache: true
|
||||
version: 0.9.5
|
||||
|
|
@ -121,23 +99,12 @@ jobs:
|
|||
readme-snippets:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
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
|
||||
id: setup-uv
|
||||
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
|
||||
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
|
||||
with:
|
||||
enable-cache: true
|
||||
version: 0.9.5
|
||||
|
|
@ -161,23 +128,12 @@ jobs:
|
|||
docs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
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
|
||||
id: setup-uv
|
||||
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
|
||||
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
|
||||
with:
|
||||
enable-cache: true
|
||||
version: 0.9.5
|
||||
|
|
|
|||
4
.github/workflows/zizmor.yml
vendored
4
.github/workflows/zizmor.yml
vendored
|
|
@ -17,9 +17,9 @@ jobs:
|
|||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Run zizmor 🌈
|
||||
uses: zizmorcore/zizmor-action@5f14fd08f7cf1cb1609c1e344975f152c7ee938d # v0.5.6
|
||||
uses: zizmorcore/zizmor-action@3dc1ecc9bcb9e94e9b2c709687979e1298497054 # v0.6.2
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
||||
- **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.
|
||||
- **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.
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ then come back.
|
|||
|
||||
## 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"
|
||||
```
|
||||
|
||||
|
|
@ -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
|
||||
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"
|
||||
```
|
||||
|
||||
`client_supports_apps(ctx)` is `True` only when the client declared the
|
||||
`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
|
||||
does not count. That is exactly what `main()` in the same file declares: the
|
||||
client half of the negotiation, and the rich answer comes back.
|
||||
does not count. Here is the client half of the negotiation:
|
||||
|
||||
```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
|
||||
Never return a placeholder like `"[Rendered UI]"` as the only content. If the
|
||||
|
|
|
|||
|
|
@ -57,7 +57,7 @@ specified by the MCP project itself.
|
|||
|
||||
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"
|
||||
```
|
||||
|
||||
|
|
@ -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;
|
||||
`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"
|
||||
--8<-- "docs_src/extensions/tutorial003.py"
|
||||
```console
|
||||
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
|
||||
|
||||
An extension can register **new request methods**: its own verbs, served next to the
|
||||
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"
|
||||
```
|
||||
|
||||
|
|
@ -107,10 +114,10 @@ runtime:
|
|||
|
||||
### 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"
|
||||
--8<-- "docs_src/extensions/tutorial004.py"
|
||||
```python title="client.py" hl_lines="21-23 27-30"
|
||||
--8<-- "docs_src/extensions/tutorial004_client.py"
|
||||
```
|
||||
|
||||
* `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`
|
||||
only grows first-class methods for spec verbs. `send_request` accepts any
|
||||
`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`
|
||||
|
||||
|
|
@ -155,13 +165,20 @@ The hook wraps `tools/call` and nothing else. For every-message concerns, use
|
|||
## Using a client extension
|
||||
|
||||
A **client extension** is the same contract from the consuming side: a bundle of
|
||||
client-side behaviour behind one identifier. Pass instances to
|
||||
`Client(extensions=[...])` and call tools normally:
|
||||
client-side behaviour behind one identifier. The server here answers `buy` with a
|
||||
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"
|
||||
```
|
||||
|
||||
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
|
||||
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
|
||||
|
|
@ -180,7 +197,7 @@ the capability, the client does nothing, as in the search client above), use
|
|||
```python
|
||||
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
|
||||
|
|
@ -188,8 +205,8 @@ client = Client(mcp, extensions=[advertise("com.example/search")])
|
|||
Subclass `ClientExtension` and override only what you need. Three contribution
|
||||
kinds, each with a default: `settings()`, `claims()`, and `notifications()`.
|
||||
|
||||
```python title="client.py" hl_lines="17-18 43-44 46-47"
|
||||
--8<-- "docs_src/extensions/tutorial006.py"
|
||||
```python title="client.py" hl_lines="16-17 25-26 28-29"
|
||||
--8<-- "docs_src/extensions/tutorial006_client.py"
|
||||
```
|
||||
|
||||
* 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
|
||||
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
|
||||
params key must ride the `Mcp-Name` header (extension specs such as tasks require
|
||||
this for their verbs), the request type declares `name_param`:
|
||||
as in [Serving your own methods](#serving-your-own-methods). Take a server whose
|
||||
extension serves one verb about a named job:
|
||||
|
||||
```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"
|
||||
```
|
||||
|
||||
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
|
||||
missing value fails loudly rather than silently omitting a required header.
|
||||
|
||||
|
|
|
|||
|
|
@ -31,18 +31,22 @@ Three things changed, and they are the whole low-level API:
|
|||
|
||||
### 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
|
||||
|
||||
from mcp import Client
|
||||
|
||||
from server import server
|
||||
|
||||
|
||||
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})
|
||||
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.
|
||||
* `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
|
||||
|
||||
`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.
|
||||
* 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)**.
|
||||
|
|
|
|||
|
|
@ -26,9 +26,13 @@ Pagination is for the server whose resource list is really a database: thousands
|
|||
|
||||
### 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"`.
|
||||
|
||||
|
|
@ -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`:
|
||||
|
||||
```python title="client.py" hl_lines="26-32"
|
||||
```python title="client.py" hl_lines="9-15"
|
||||
--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.
|
||||
* `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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
||||
```python title="server.py" hl_lines="10 16"
|
||||
```python title="server.py" hl_lines="11 17"
|
||||
--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.
|
||||
|
||||
```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"
|
||||
```
|
||||
|
||||
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`):
|
||||
|
||||
* `"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.
|
||||
|
||||
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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
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
|
||||
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.
|
||||
|
||||
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.
|
||||
* `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
Loading…
Reference in a new issue