From 0921d94a74db900dccd2d534842aa7b6160542d2 Mon Sep 17 00:00:00 2001 From: Max <224885523+maxisbey@users.noreply.github.com> Date: Tue, 25 Aug 2026 16:39:57 +0100 Subject: [PATCH 01/20] Point imports of mcp.server.fastmcp at the migration guide (#3388) --- docs/migration.md | 4 ++- scripts/docs/gen_ref_pages.py | 11 +++---- src/mcp/server/fastmcp.py | 16 ++++++++++ tests/server/test_fastmcp.py | 55 +++++++++++++++++++++++++++++++++++ 4 files changed, 80 insertions(+), 6 deletions(-) create mode 100644 src/mcp/server/fastmcp.py create mode 100644 tests/server/test_fastmcp.py diff --git a/docs/migration.md b/docs/migration.md index 25e366d8..7927c606 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -17,7 +17,7 @@ Every section heading below names the API it affects, so searching this page for | Change | First symptom | Section | |---|---|---| -| `FastMCP` renamed to `MCPServer` | `ModuleNotFoundError: No module named 'mcp.server.fastmcp'` | [`FastMCP` renamed](#fastmcp-renamed-to-mcpserver) | +| `FastMCP` renamed to `MCPServer` | `ModuleNotFoundError: No module named 'mcp.server.fastmcp'` (newer 2.x releases follow it with a pointer to this guide) | [`FastMCP` renamed](#fastmcp-renamed-to-mcpserver) | | Fields renamed from camelCase to snake_case | `AttributeError: 'Tool' object has no attribute 'inputSchema'` | [snake_case fields](#field-names-changed-from-camelcase-to-snake_case) | | `mcp.types` names removed | `ImportError: cannot import name 'Content' from 'mcp.types'` | [Removed types](#removed-type-aliases-and-classes) | | `McpError` renamed to `MCPError` | `ImportError: cannot import name 'McpError' from 'mcp'` | [`McpError` renamed](#mcperror-renamed-to-mcperror) | @@ -672,6 +672,8 @@ All submodules under `mcp.server.fastmcp.*` are now under `mcp.server.mcpserver. - `ToolError`, `ResourceError` — from `mcp.server.mcpserver.exceptions` - `MCPServerError` (renamed from `FastMCPError`) — from `mcp.server.mcpserver.exceptions` +Importing `mcp.server.fastmcp`, or anything below it, raises `ModuleNotFoundError` (newer 2.x releases include a link to this section in its message), so existing `except ImportError` or `except ModuleNotFoundError` fallbacks around the v1 import keep working. + ### What is unchanged on `MCPServer` Beyond the changes covered in this section, the everyday `FastMCP` surface carries over to `MCPServer` as-is: diff --git a/scripts/docs/gen_ref_pages.py b/scripts/docs/gen_ref_pages.py index 42c75616..c368ff89 100644 --- a/scripts/docs/gen_ref_pages.py +++ b/scripts/docs/gen_ref_pages.py @@ -31,11 +31,12 @@ API_DIR = ROOT / "docs" / "api" # it from `src/` would emit the unimportable `mcp-types.mcp_types.*`. PACKAGES = (ROOT / "src" / "mcp", ROOT / "src" / "mcp-types" / "mcp_types") -# Alias packages that mirror another package's namespaces (`mcp.types` mirrors -# `mcp_types`, `mcp.types.version` mirrors `mcp_types.version`): the mirrored -# package's pages are the canonical rendering, so an alias, and every module -# under it, earns no page of its own. -EXCLUDED = frozenset({"mcp.types"}) +# Module paths that get no page, and neither does anything under them: alias +# packages that mirror another package's namespaces (`mcp.types` mirrors +# `mcp_types`), whose canonical rendering is the mirrored package's pages; and +# removed v1 import paths (`mcp.server.fastmcp`) that only raise a pointer to +# the migration guide and carry no API. +EXCLUDED = frozenset({"mcp.types", "mcp.server.fastmcp"}) _KIND_SECTIONS = { griffe.Kind.MODULE: "Modules", diff --git a/src/mcp/server/fastmcp.py b/src/mcp/server/fastmcp.py new file mode 100644 index 00000000..7dd4646d --- /dev/null +++ b/src/mcp/server/fastmcp.py @@ -0,0 +1,16 @@ +"""Removed in mcp 2: `FastMCP` is now `mcp.server.mcpserver.MCPServer`. + +This module has no API. Importing it, or anything below it, raises +`ModuleNotFoundError` with a message that points at the migration guide. It +exists only because the bare "No module named 'mcp.server.fastmcp'" gave v1 +code no hint that the installed SDK is a different major version. +""" + +_MESSAGE = ( + "No module named 'mcp.server.fastmcp'. This is mcp 2.x, where FastMCP was renamed to MCPServer " + "(from mcp.server.mcpserver import MCPServer) and other APIs changed; see the migration guide at " + "https://py.sdk.modelcontextprotocol.io/v2/migration/#fastmcp-renamed-to-mcpserver " + "or pin 'mcp<2' to keep running v1 code." +) + +raise ModuleNotFoundError(_MESSAGE, name=__name__) diff --git a/tests/server/test_fastmcp.py b/tests/server/test_fastmcp.py new file mode 100644 index 00000000..b15c5191 --- /dev/null +++ b/tests/server/test_fastmcp.py @@ -0,0 +1,55 @@ +"""The removed v1 import path `mcp.server.fastmcp` fails with a pointer to the migration guide.""" + +import importlib +import sys + +import pytest +from inline_snapshot import snapshot + +import mcp.server +from mcp.server.mcpserver import MCPServer + + +def test_importing_fastmcp_raises_module_not_found_that_points_at_the_migration_guide() -> None: + """SDK-defined: the v1 path fails with the same exception type and `.name` as a module + that genuinely does not exist, but the message names the replacement and the guide.""" + with pytest.raises(ModuleNotFoundError) as exc_info: + importlib.import_module("mcp.server.fastmcp") + + assert exc_info.value.name == "mcp.server.fastmcp" + assert str(exc_info.value) == snapshot( + "No module named 'mcp.server.fastmcp'. This is mcp 2.x, where FastMCP was renamed to MCPServer " + "(from mcp.server.mcpserver import MCPServer) and other APIs changed; see the migration guide at " + "https://py.sdk.modelcontextprotocol.io/v2/migration/#fastmcp-renamed-to-mcpserver " + "or pin 'mcp<2' to keep running v1 code." + ) + # A module that raises while executing is never cached, so nothing is left behind. + assert "mcp.server.fastmcp" not in sys.modules + assert not hasattr(mcp.server, "fastmcp") + + +def test_importing_a_fastmcp_submodule_raises_the_parent_pointer() -> None: + """SDK-defined: a deep v1 path executes `mcp.server.fastmcp` first, so it fails with that + module's message and `.name` rather than a bare error for the leaf.""" + with pytest.raises(ModuleNotFoundError) as parent: + importlib.import_module("mcp.server.fastmcp") + with pytest.raises(ModuleNotFoundError) as exc_info: + importlib.import_module("mcp.server.fastmcp.utilities.types") + + assert exc_info.value.name == "mcp.server.fastmcp" + assert str(exc_info.value) == str(parent.value) + + +def test_v1_first_import_shim_falls_back_to_mcpserver() -> None: + """SDK-defined: projects that support both majors try the v1 import and fall back on + `ModuleNotFoundError` (the narrowest guard seen in the wild), which is why the pointer is + raised as exactly that type and not as a bare `ImportError` or after a warning.""" + fell_back = False + try: + server_class: type = importlib.import_module("mcp.server.fastmcp").FastMCP + except ModuleNotFoundError: + fell_back = True + server_class = MCPServer + + assert fell_back + assert server_class is MCPServer From e7284ed130bb86298cf61b4a65e1535eb91180b4 Mon Sep 17 00:00:00 2001 From: Max <224885523+maxisbey@users.noreply.github.com> Date: Wed, 26 Aug 2026 12:02:43 +0100 Subject: [PATCH 02/20] Gate draft PRs too and rewrite the auto-close comment (#3378) --- .github/scripts/pr_intake_gate.js | 43 ++++++++++++++-------- .github/scripts/pr_intake_gate.test.js | 13 ++++--- .github/workflows/require-linked-issue.yml | 6 +-- 3 files changed, 38 insertions(+), 24 deletions(-) diff --git a/.github/scripts/pr_intake_gate.js b/.github/scripts/pr_intake_gate.js index 40f429d6..0fb2864d 100644 --- a/.github/scripts/pr_intake_gate.js +++ b/.github/scripts/pr_intake_gate.js @@ -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 #\` 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 #` 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 #` 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'); } diff --git a/.github/scripts/pr_intake_gate.test.js b/.github/scripts/pr_intake_gate.test.js index 75c96247..a7cde4bf 100644 --- a/.github/scripts/pr_intake_gate.test.js +++ b/.github/scripts/pr_intake_gate.test.js @@ -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('')); 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; } diff --git a/.github/workflows/require-linked-issue.yml b/.github/workflows/require-linked-issue.yml index de036ebf..31ccdc3e 100644 --- a/.github/workflows/require-linked-issue.yml +++ b/.github/workflows/require-linked-issue.yml @@ -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 From 6705402e246dc4eb1fcdf4d9902b78d3c9c36e1b Mon Sep 17 00:00:00 2001 From: Max <224885523+maxisbey@users.noreply.github.com> Date: Wed, 26 Aug 2026 14:15:18 +0100 Subject: [PATCH 03/20] Resolve tool output-schema references within the schema document only (#3394) --- src/mcp/client/session.py | 22 ++++++++++------ tests/client/test_output_schema_validation.py | 25 +++++++++++++++++++ 2 files changed, 39 insertions(+), 8 deletions(-) diff --git a/src/mcp/client/session.py b/src/mcp/client/session.py index f18cc0ef..aa2406dc 100644 --- a/src/mcp/client/session.py +++ b/src/mcp/client/session.py @@ -1126,7 +1126,8 @@ class ClientSession: """Revalidate a `CallToolResult` against the tool's declared output schema. Raises: - RuntimeError: Structured content is missing or does not conform to the schema. + RuntimeError: Structured content is missing or does not conform to the schema, or the + schema is invalid or has a `$ref` that does not resolve within the schema document. """ if name not in self._tool_output_schemas: # refresh output schema cache @@ -1140,6 +1141,7 @@ class ClientSession: if output_schema is not None: from jsonschema import exceptions as jsonschema_exceptions + from referencing.exceptions import Unresolvable if result.structured_content is None: raise RuntimeError(f"Tool {name} has an output schema but did not return structured content") @@ -1147,10 +1149,14 @@ class ClientSession: # `best_match` picks the same error the previous `jsonschema.validate()` call raised, # so the message a caller sees is unchanged. It is untyped upstream. errors = validator.iter_errors(result.structured_content) - error = cast( - "Exception | None", - jsonschema_exceptions.best_match(errors), # pyright: ignore[reportUnknownMemberType] - ) + try: + error = cast( + "Exception | None", + jsonschema_exceptions.best_match(errors), # pyright: ignore[reportUnknownMemberType] + ) + except Unresolvable as e: + # A `$ref` did not resolve within the schema document. + raise RuntimeError(f"Invalid schema for tool {name}: {e}") from e if error is not None: raise RuntimeError(f"Invalid structured content returned by tool {name}: {error}") from error @@ -1168,6 +1174,7 @@ class ClientSession: """ from jsonschema import SchemaError from jsonschema.validators import validator_for + from referencing import Registry if (validator := self._tool_output_validators.get(name)) is not None: return validator @@ -1177,9 +1184,8 @@ class ClientSession: validator_cls.check_schema(output_schema) except SchemaError as e: raise RuntimeError(f"Invalid schema for tool {name}: {e}") - # jsonschema ships no `py.typed`, so pyright reads typeshed's stub, which declares - # `registry` as required (concrete validators default it); cast to a schema-only ctor. - validator = cast("Callable[[dict[str, Any]], Validator]", validator_cls)(output_schema) + # An explicit empty registry: `$ref`s resolve within the schema document and the bundled metaschemas. + validator = validator_cls(output_schema, registry=Registry()) self._tool_output_validators[name] = validator return validator diff --git a/tests/client/test_output_schema_validation.py b/tests/client/test_output_schema_validation.py index 60f6fadc..10715e63 100644 --- a/tests/client/test_output_schema_validation.py +++ b/tests/client/test_output_schema_validation.py @@ -1,4 +1,5 @@ import logging +from pathlib import Path from typing import Any import pytest @@ -10,6 +11,7 @@ from mcp_types import ( TextContent, Tool, ) +from referencing.exceptions import Unresolvable from mcp import Client from mcp.server import Server, ServerRequestContext @@ -163,3 +165,26 @@ async def test_tool_not_listed_warning(caplog: pytest.LogCaptureFixture): assert result.is_error is False assert "Tool mystery_tool not listed" in caplog.text + + +# jsonschema's fallback retriever emits this DeprecationWarning; keep it a plain warning so the +# assertions below decide the outcome rather than the suite's warnings-as-errors filter. +@pytest.mark.filterwarnings("default:Automatically retrieving remote references:DeprecationWarning") +@pytest.mark.anyio +async def test_output_schema_ref_outside_the_document_is_rejected(tmp_path: Path): + """A `$ref` to a URI outside the output schema is not resolved, and a result whose validation + reaches one fails as an invalid schema (spec `$ref` resolution; applying it to `file:` URIs too + is SDK-defined).""" + target = tmp_path / "schema.json" + target.write_text("{}", encoding="utf-8") + server = _make_server( + tools=[Tool(name="probe", input_schema={"type": "object"}, output_schema={"$ref": target.as_uri()})], + structured_content={"v": 1}, + ) + + async with Client(server) as client: + with pytest.raises(RuntimeError) as exc_info: + await client.call_tool("probe", {}) + # SDK-authored prefix only; the tail is `referencing`'s text. + assert str(exc_info.value).startswith("Invalid schema for tool probe: ") + assert isinstance(exc_info.value.__cause__, Unresolvable) From d2290ca3434731b68ea3e2270bc06a6e6575931b Mon Sep 17 00:00:00 2001 From: Max <224885523+maxisbey@users.noreply.github.com> Date: Fri, 28 Aug 2026 15:54:49 +0100 Subject: [PATCH 04/20] Expire idle Streamable HTTP sessions by default and cap concurrent sessions (#3395) --- docs/run/index.md | 5 + docs/run/legacy-clients.md | 34 + docs/troubleshooting.md | 8 +- src/mcp/server/lowlevel/server.py | 11 +- src/mcp/server/mcpserver/server.py | 16 +- src/mcp/server/streamable_http.py | 49 +- src/mcp/server/streamable_http_manager.py | 277 ++++---- tests/docs_src/test_asgi.py | 16 +- tests/docs_src/test_legacy_clients.py | 16 + .../transports/test_hosting_session.py | 14 +- tests/server/test_streamable_http_manager.py | 600 +++++++++++++++--- tests/server/test_streamable_http_router.py | 16 + tests/shared/test_streamable_http.py | 99 ++- 13 files changed, 921 insertions(+), 240 deletions(-) diff --git a/docs/run/index.md b/docs/run/index.md index da54dc31..fb23b4bb 100644 --- a/docs/run/index.md +++ b/docs/run/index.md @@ -70,6 +70,11 @@ Each transport has its own keyword arguments, all on `run()`: * `max_request_body_size`: largest accepted request body in bytes. Defaults to 4 MiB; larger requests receive HTTP 413 before parsing or session creation. Raise it only when legitimate MCP messages exceed that size. +* `session_idle_timeout`: seconds a legacy session may sit with nothing in flight before the + server closes it. Default 1800. `None` disables it. See + [Session lifetime and limits](legacy-clients.md#session-lifetime-and-limits). +* `max_sessions`: how many legacy sessions one process holds at once. Default 10 000. `None` + removes the limit. Covered in the same section. * `event_store`, `retry_interval`, `transport_security`: resumability and DNS-rebinding protection. They can wait, until you deploy somewhere other than localhost; **[Deploy & scale](deploy.md)** covers `transport_security`. !!! warning diff --git a/docs/run/legacy-clients.md b/docs/run/legacy-clients.md index a1c0f760..699632ad 100644 --- a/docs/run/legacy-clients.md +++ b/docs/run/legacy-clients.md @@ -56,6 +56,40 @@ On one worker that is invisible. On two, it is the whole problem: a request that events to a client reconnecting to the *same* session), not a session store. It never makes a session reachable from another process. +## Session lifetime and limits + +A legacy session does not live forever, and one process does not hold an unlimited number of +them. Two settings control this. Both are keyword arguments on `run()`, `streamable_http_app()` +and `Server.streamable_http_app()`. Modern (`2026-07-28`) connections and `stateless_http=True` +have no sessions, so neither setting applies to them. + +| Setting | Default | What it does | What the client sees | Turn it off | +|---|---|---|---|---| +| `session_idle_timeout` | `1800` (30 min) | Closes a session that has had nothing in flight for that long. | `404 Session not found`. It has to `initialize` again. | `None` | +| `max_sessions` | `10_000` | Refuses to open a session beyond that many. Existing sessions are untouched and nothing is evicted. | `503 Too many open sessions` with JSON-RPC code `-32603`. | `None` | + +What counts as "in flight": + +* An open `GET` stream. The SDK clients keep one open, so a connected client's session never + expires. +* A request that is still being answered. A tool call that runs longer than the timeout is not + interrupted, and the countdown only starts once it finishes. +* Nothing else. Between requests the clock runs. Any request on the session restarts it, + `ping` included. Once a session has expired, nothing revives it. + +A client that ends its session with `DELETE` frees it immediately. So does a client whose +opening request was refused. + +```python +mcp.run(transport="streamable-http", session_idle_timeout=None, max_sessions=50_000) +``` + +Both events show up in the server log. An expiry is `Session idle timeout` at `INFO`. A +refused open is `Refusing to open a new session: sessions are already open` at `WARNING`. + +The limits are per process. With four workers the ceiling is four times `max_sessions`, and each +worker expires its own sessions. + ## The one knob: `stateless_http` If stickiness is a cost you refuse to pay, there is exactly one thing you can change. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index b0e365ca..838c92ca 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -246,7 +246,7 @@ app = Starlette(routes=[Mount("/", app=mcp.streamable_http_app())], lifespan=lif ## `MCPError: Session not found` -The server does not recognise the `Mcp-Session-Id` your client sent, almost always because the server **restarted** (or you were routed to a different instance). Sessions live in that one process's memory. +The server does not recognise the `Mcp-Session-Id` your client sent. Either the server **restarted** (or you were routed to a different instance), or the session **expired** because nothing was in flight for `session_idle_timeout`, which is 30 minutes by default. See [Session lifetime and limits](run/legacy-clients.md#session-lifetime-and-limits). Sessions live in that one process's memory. There is no server bug to find. The HTTP response is a `404` whose body *is* JSON-RPC, so, unlike the `421` above, the python `Client` shows you this one verbatim: @@ -256,9 +256,9 @@ There is no server bug to find. The HTTP response is a `404` whose body *is* JSO The fix is to reconnect: leave the `async with Client(...)` block and enter a new one, which negotiates a fresh session. For a long-lived client, that means catching `MCPError` around your calls and reconnecting on this message rather than retrying inside a dead session. -If it happens *without* a restart, you are running more than one worker without sticky sessions: each worker holds its own session table, so a request routed to the wrong one lands here. **[Deploy & scale](run/deploy.md)** and **[Serving legacy clients](run/legacy-clients.md)** own that story and its two fixes (sticky routing, or `stateless_http=True`). +If it happens *without* a restart and without the client having gone quiet that long, you are running more than one worker without sticky sessions: each worker holds its own session table, so a request routed to the wrong one lands here. **[Deploy & scale](run/deploy.md)** and **[Serving legacy clients](run/legacy-clients.md)** own that story and its two fixes (sticky routing, or `stateless_http=True`). -For the server operator, the matching log line is `Rejected request with unknown or expired session ID: `. It is logged at `INFO`, so it is invisible at the usual `WARNING` threshold. Seeing it in bursts right after a deploy is normal; every connected client is reconnecting. +For the server operator, the matching log line is `Rejected request with unknown or expired session ID: `. It is logged at `INFO`, so it is invisible at the usual `WARNING` threshold. Seeing it in bursts right after a deploy is normal; every connected client is reconnecting. When the session expired instead, that line is preceded by `Session idle timeout`, also at `INFO`. ## `MCPError: Method not found` @@ -411,7 +411,7 @@ mcp = MCPServer("Weather", request_state_security=RequestStateSecurity(keys=[key * `Tool already exists:` in the server log is the only sign that two same-named tools collapsed into one. * One 421, three spellings: `Server returned an error response` (the python `Client`), `421 Misdirected Request` / `Invalid Host header` (everything else), `Invalid Host header: ` (the server log). Fix: `transport_security=TransportSecuritySettings(allowed_hosts=[...])`. * `Task group is not initialized` -> a mounted app whose host lifespan never entered `mcp.session_manager.run()`. -* `Session not found` -> the server restarted; reconnect. +* `Session not found` -> the server restarted or the session expired (`session_idle_timeout`); reconnect. * `Cannot send 'elicitation/create': ... no back-channel ...` -> `ctx.elicit()` needs a server-to-client channel: a `2026-07-28` connection never has one, `stateless_http=True` takes away the legacy one, and `json_response=True` takes away the request-scoped one. Use a resolver (a legacy client also needs a server that keeps the channel). Its neighbour `Method not found` is a request for a method the other side's protocol revision doesn't have. * `Client did not declare the form elicitation capability ...` and `Elicitation not supported` -> the client is missing `elicitation_callback=`. * `Invalid or expired requestState` never says why on the wire. The server log does; `unknown key` means share `RequestStateSecurity(keys=[...])` across workers. diff --git a/src/mcp/server/lowlevel/server.py b/src/mcp/server/lowlevel/server.py index 4c327f4e..6df5341e 100644 --- a/src/mcp/server/lowlevel/server.py +++ b/src/mcp/server/lowlevel/server.py @@ -65,7 +65,12 @@ from mcp.server.context import HandlerResult, ServerMiddleware, ServerRequestCon from mcp.server.models import InitializationOptions from mcp.server.runner import serve_dual_era_loop from mcp.server.streamable_http import EventStore -from mcp.server.streamable_http_manager import StreamableHTTPASGIApp, StreamableHTTPSessionManager +from mcp.server.streamable_http_manager import ( + DEFAULT_MAX_SESSIONS, + DEFAULT_SESSION_IDLE_TIMEOUT, + StreamableHTTPASGIApp, + StreamableHTTPSessionManager, +) from mcp.server.transport_security import DEFAULT_MAX_REQUEST_BODY_SIZE, TransportSecuritySettings from mcp.shared._stream_protocols import ReadStream, WriteStream from mcp.shared.exceptions import MCPDeprecationWarning @@ -722,6 +727,8 @@ class Server(Generic[LifespanResultT]): event_store: EventStore | None = None, retry_interval: int | None = None, max_request_body_size: int = DEFAULT_MAX_REQUEST_BODY_SIZE, + session_idle_timeout: float | None = DEFAULT_SESSION_IDLE_TIMEOUT, + max_sessions: int | None = DEFAULT_MAX_SESSIONS, transport_security: TransportSecuritySettings | None = None, host: str = "127.0.0.1", auth: AuthSettings | None = None, @@ -747,6 +754,8 @@ class Server(Generic[LifespanResultT]): stateless=stateless_http, security_settings=transport_security, max_request_body_size=max_request_body_size, + session_idle_timeout=session_idle_timeout, + max_sessions=max_sessions, ) self._session_manager = session_manager diff --git a/src/mcp/server/mcpserver/server.py b/src/mcp/server/mcpserver/server.py index 93bef165..1f3e863d 100644 --- a/src/mcp/server/mcpserver/server.py +++ b/src/mcp/server/mcpserver/server.py @@ -93,7 +93,11 @@ from mcp.server.request_state import RequestStateBoundary, RequestStateSecurity from mcp.server.sse import SseServerTransport from mcp.server.stdio import stdio_server from mcp.server.streamable_http import EventStore -from mcp.server.streamable_http_manager import StreamableHTTPSessionManager +from mcp.server.streamable_http_manager import ( + DEFAULT_MAX_SESSIONS, + DEFAULT_SESSION_IDLE_TIMEOUT, + StreamableHTTPSessionManager, +) from mcp.server.subscriptions import InMemorySubscriptionBus, ListenHandler, SubscriptionBus from mcp.server.transport_security import DEFAULT_MAX_REQUEST_BODY_SIZE, TransportSecuritySettings from mcp.shared.exceptions import MCPError @@ -388,6 +392,8 @@ class MCPServer(Generic[LifespanResultT]): event_store: EventStore | None = ..., retry_interval: int | None = ..., max_request_body_size: int = ..., + session_idle_timeout: float | None = ..., + max_sessions: int | None = ..., transport_security: TransportSecuritySettings | None = ..., ) -> None: ... @@ -1106,6 +1112,8 @@ class MCPServer(Generic[LifespanResultT]): event_store: EventStore | None = None, retry_interval: int | None = None, max_request_body_size: int = DEFAULT_MAX_REQUEST_BODY_SIZE, + session_idle_timeout: float | None = DEFAULT_SESSION_IDLE_TIMEOUT, + max_sessions: int | None = DEFAULT_MAX_SESSIONS, transport_security: TransportSecuritySettings | None = None, ) -> None: """Run the server using StreamableHTTP transport.""" @@ -1118,6 +1126,8 @@ class MCPServer(Generic[LifespanResultT]): event_store=event_store, retry_interval=retry_interval, max_request_body_size=max_request_body_size, + session_idle_timeout=session_idle_timeout, + max_sessions=max_sessions, transport_security=transport_security, host=host, ) @@ -1270,6 +1280,8 @@ class MCPServer(Generic[LifespanResultT]): event_store: EventStore | None = None, retry_interval: int | None = None, max_request_body_size: int = DEFAULT_MAX_REQUEST_BODY_SIZE, + session_idle_timeout: float | None = DEFAULT_SESSION_IDLE_TIMEOUT, + max_sessions: int | None = DEFAULT_MAX_SESSIONS, transport_security: TransportSecuritySettings | None = None, host: str = "127.0.0.1", ) -> Starlette: @@ -1281,6 +1293,8 @@ class MCPServer(Generic[LifespanResultT]): event_store=event_store, retry_interval=retry_interval, max_request_body_size=max_request_body_size, + session_idle_timeout=session_idle_timeout, + max_sessions=max_sessions, transport_security=transport_security, host=host, auth=self.settings.auth, diff --git a/src/mcp/server/streamable_http.py b/src/mcp/server/streamable_http.py index 1a4e9939..416dd9e2 100644 --- a/src/mcp/server/streamable_http.py +++ b/src/mcp/server/streamable_http.py @@ -7,6 +7,7 @@ responses, with streaming support for long-running operations. """ import logging +import math import re from abc import ABC, abstractmethod from collections.abc import AsyncGenerator, Awaitable, Callable @@ -167,6 +168,7 @@ class StreamableHTTPServerTransport: event_store: EventStore | None = None, security_settings: TransportSecuritySettings | None = None, retry_interval: int | None = None, + idle_timeout: float | None = None, ) -> None: """Initialize a new StreamableHTTP server transport. @@ -187,12 +189,22 @@ class StreamableHTTPServerTransport: retry field. When set, the server will send a retry field in SSE priming events to control client reconnection timing for polling behavior. Only used when event_store is provided. + idle_timeout: Seconds the session may go without any request in flight before + `idle_scope` is cancelled. A request being served or an open GET + stream holds the session open; the countdown starts each time the + last in-flight request completes. The host enters `idle_scope` + (available once `connect()` has been entered) around the session's + message loop to end the session when it fires. Default is None: no + `idle_scope`, the session never expires. Raises: - ValueError: If the session ID contains invalid characters. + ValueError: If the session ID contains invalid characters, or if `idle_timeout` + is not a positive, finite number. """ if mcp_session_id is not None and not SESSION_ID_PATTERN.fullmatch(mcp_session_id): raise ValueError("Session ID must only contain visible ASCII characters (0x21-0x7E)") + if idle_timeout is not None and not (math.isfinite(idle_timeout) and idle_timeout > 0): + raise ValueError("idle_timeout must be a positive, finite number of seconds") self.mcp_session_id = mcp_session_id self.is_json_response_enabled = is_json_response_enabled @@ -208,8 +220,11 @@ class StreamableHTTPServerTransport: ] = {} self._sse_stream_writers: dict[RequestId, MemoryObjectSendStream[SSEEvent]] = {} self._terminated = False - # Idle timeout cancel scope; managed by the session manager. + self._idle_timeout = idle_timeout + self._requests_in_flight = 0 self.idle_scope: anyio.CancelScope | None = None + """Created when `connect()` is entered if `idle_timeout` is set; cancelled once no request has been in + flight for `idle_timeout` seconds.""" @property def is_terminated(self) -> bool: @@ -458,6 +473,32 @@ class StreamableHTTPServerTransport: async def handle_request(self, scope: Scope, receive: Receive, send: Send) -> None: """Application entry point that handles all HTTP requests.""" + if self.idle_scope is None or self._idle_timeout is None: + await self._handle_request(scope, receive, send) + return + + if self.idle_scope.cancel_called: + # The idle period already ran out and the host is ending this + # session: answer as terminated rather than dispatch into a + # message loop that is going away. + if not self._terminated: + await self.terminate() + await self._handle_request(scope, receive, send) + return + + # A request in flight (an open GET stream included) holds the session: + # the idle countdown is suspended while any is being served and + # restarts when the last one completes. + self._requests_in_flight += 1 + self.idle_scope.deadline = math.inf + try: + await self._handle_request(scope, receive, send) + finally: + self._requests_in_flight -= 1 + if not self._requests_in_flight: + self.idle_scope.deadline = anyio.current_time() + self._idle_timeout + + async def _handle_request(self, scope: Scope, receive: Receive, send: Send) -> None: request = Request(scope, receive) # Validate request headers for DNS rebinding protection @@ -793,7 +834,7 @@ class StreamableHTTPServerTransport: await response(request.scope, request.receive, send) return - if not await self._validate_request_headers(request, send): # pragma: no cover + if not await self._validate_request_headers(request, send): return await self.terminate() @@ -995,6 +1036,8 @@ class StreamableHTTPServerTransport: Yields: Tuple of (read_stream, write_stream) for bidirectional communication """ + if self._idle_timeout is not None: + self.idle_scope = anyio.CancelScope() # Create the memory streams for this connection diff --git a/src/mcp/server/streamable_http_manager.py b/src/mcp/server/streamable_http_manager.py index e9a7d962..a7efac3f 100644 --- a/src/mcp/server/streamable_http_manager.py +++ b/src/mcp/server/streamable_http_manager.py @@ -4,17 +4,18 @@ from __future__ import annotations import contextlib import logging +import math from collections.abc import AsyncIterator -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, Final from uuid import uuid4 import anyio from anyio.abc import TaskStatus -from mcp_types import DEFAULT_NEGOTIATED_VERSION, INVALID_REQUEST, ErrorData, JSONRPCError +from mcp_types import DEFAULT_NEGOTIATED_VERSION, INTERNAL_ERROR, INVALID_REQUEST, ErrorData, JSONRPCError from mcp_types.version import HANDSHAKE_PROTOCOL_VERSIONS from starlette.requests import Request from starlette.responses import Response -from starlette.types import Receive, Scope, Send +from starlette.types import ASGIApp, Message, Receive, Scope, Send from mcp.server._streamable_http_modern import handle_modern_request from mcp.server.auth.middleware.bearer_auth import AuthenticatedUser, AuthorizationContext, authorization_context @@ -34,6 +35,12 @@ if TYPE_CHECKING: logger = logging.getLogger(__name__) +DEFAULT_SESSION_IDLE_TIMEOUT: Final = 30 * 60 +"""Default idle period in seconds after which a stateful Streamable HTTP session is closed (30 minutes).""" + +DEFAULT_MAX_SESSIONS: Final = 10_000 +"""Default maximum number of concurrent stateful Streamable HTTP sessions per session manager.""" + class StreamableHTTPSessionManager: """Manages StreamableHTTP sessions with optional resumability via event store. @@ -45,7 +52,7 @@ class StreamableHTTPSessionManager: 2. Resumability via an optional event store 3. Connection management and lifecycle 4. Request handling and transport setup - 5. Idle session cleanup via optional timeout + 5. Idle session cleanup Important: Only one StreamableHTTPSessionManager instance should be created per application. The instance cannot be reused after its run() context has @@ -62,13 +69,18 @@ class StreamableHTTPSessionManager: security_settings: Optional transport security settings. retry_interval: Retry interval in milliseconds to suggest to clients in SSE retry field. Used for SSE polling behavior. - session_idle_timeout: Optional idle timeout in seconds for stateful sessions. If set, sessions that - receive no HTTP requests for this duration will be automatically terminated and removed. When - retry_interval is also configured, ensure the idle timeout comfortably exceeds the retry interval to - avoid reaping sessions during normal SSE polling gaps. Default is None (no timeout). A value of 1800 - (30 minutes) is recommended for most deployments. + session_idle_timeout: Idle timeout in seconds for stateful sessions. A session that has had no HTTP + request in flight for this long (no request being served, no open GET stream) is terminated and + removed; its ID then answers 404 and the client has to initialize a new session. When retry_interval + is also configured, ensure the idle timeout comfortably exceeds the retry interval to avoid reaping + sessions during normal SSE polling gaps. Defaults to 1800 (30 minutes); None disables the timeout so + sessions live until the client deletes them or the manager shuts down. Unused in stateless mode. max_request_body_size: Maximum size in bytes for Streamable HTTP request bodies. Requests that exceed this limit receive a 413 response before parsing or session creation. Defaults to 4 MiB. + max_sessions: Maximum number of concurrent stateful sessions. While that many sessions are open, a + request that would open another one receives a 503 response; existing sessions are unaffected and + room frees up as they end or expire. Defaults to 10 000; None removes the limit. Unused in stateless + mode. """ def __init__( @@ -79,15 +91,16 @@ class StreamableHTTPSessionManager: stateless: bool = False, security_settings: TransportSecuritySettings | None = None, retry_interval: int | None = None, - session_idle_timeout: float | None = None, + session_idle_timeout: float | None = DEFAULT_SESSION_IDLE_TIMEOUT, max_request_body_size: int = DEFAULT_MAX_REQUEST_BODY_SIZE, + max_sessions: int | None = DEFAULT_MAX_SESSIONS, ): - if session_idle_timeout is not None and session_idle_timeout <= 0: - raise ValueError("session_idle_timeout must be a positive number of seconds") - if stateless and session_idle_timeout is not None: - raise RuntimeError("session_idle_timeout is not supported in stateless mode") + if session_idle_timeout is not None and not (math.isfinite(session_idle_timeout) and session_idle_timeout > 0): + raise ValueError("session_idle_timeout must be a positive, finite number of seconds") if max_request_body_size <= 0: raise ValueError("max_request_body_size must be a positive number of bytes") + if max_sessions is not None and max_sessions <= 0: + raise ValueError("max_sessions must be a positive number of sessions or None") self.app = app self.event_store = event_store @@ -97,6 +110,7 @@ class StreamableHTTPSessionManager: self.retry_interval = retry_interval self.session_idle_timeout = session_idle_timeout self.max_request_body_size = max_request_body_size + self.max_sessions = max_sessions self.asgi_app = RequestBodyLimitMiddleware(self._handle_request, max_request_body_size) # Session tracking (only used if not stateless) @@ -234,16 +248,15 @@ class StreamableHTTPSessionManager: except Exception: # pragma: lax no cover logger.exception("Stateless session crashed") - # Assert task group is not None for type checking + # The per-request server task only ends once the transport is + # terminated, so terminate it even if the request was cancelled. assert self._task_group is not None - # Start the server task - await self._task_group.start(run_stateless_server) - - # Handle the HTTP request and return the response - await http_transport.handle_request(scope, receive, send) - - # Terminate the transport after the request is handled - await http_transport.terminate() + try: + await self._task_group.start(run_stateless_server) + await http_transport.handle_request(scope, receive, send) + finally: + with anyio.CancelScope(shield=True): + await http_transport.terminate() async def _handle_stateful_request(self, scope: Scope, receive: Receive, send: Send) -> None: """Process request in stateful mode - maintaining session state between requests.""" @@ -263,109 +276,145 @@ class StreamableHTTPSessionManager: "Rejecting request for session %s: credential does not match the one that created the session", request_mcp_session_id[:64], ) - body = JSONRPCError( - jsonrpc="2.0", id=None, error=ErrorData(code=INVALID_REQUEST, message="Session not found") - ) - response = Response( - body.model_dump_json(by_alias=True, exclude_unset=True), - status_code=404, - media_type="application/json", - ) - await response(scope, receive, send) + await _error_response("Session not found", 404)(scope, receive, send) return logger.debug("Session already exists, handling request directly") - # Push back idle deadline on activity - if transport.idle_scope is not None and self.session_idle_timeout is not None: - transport.idle_scope.deadline = anyio.current_time() + self.session_idle_timeout # pragma: no cover await transport.handle_request(scope, receive, send) + if transport.is_terminated: + # The client ended the session (DELETE): forget it now rather + # than when its server task winds down. + await self._discard_session(request_mcp_session_id, transport) return if request_mcp_session_id is None: - # New session case - logger.debug("Creating new transport") + # New session case. Admission (the session limit and registration) + # is decided under the lock; the request itself is served outside + # it, so one client that is slow to send its opening request does + # not hold up the others. async with self._session_creation_lock: - new_session_id = uuid4().hex - http_transport = StreamableHTTPServerTransport( - mcp_session_id=new_session_id, - is_json_response_enabled=self.json_response, - event_store=self.event_store, # May be None (no resumability) - security_settings=self.security_settings, - retry_interval=self.retry_interval, - ) - - assert http_transport.mcp_session_id is not None - if requestor is not None: - self._session_owners[http_transport.mcp_session_id] = requestor - self._server_instances[http_transport.mcp_session_id] = http_transport - logger.info(f"Created new transport with session ID: {new_session_id}") - - # Define the server runner - async def run_server(*, task_status: TaskStatus[None] = anyio.TASK_STATUS_IGNORED) -> None: - async with http_transport.connect() as streams: - read_stream, write_stream = streams - task_status.started() - try: - # Use a cancel scope for idle timeout — when the - # deadline passes the scope cancels the loop and - # execution continues after the ``with`` block. - # Incoming requests push the deadline forward. - idle_scope = anyio.CancelScope() - if self.session_idle_timeout is not None: - idle_scope.deadline = anyio.current_time() + self.session_idle_timeout - http_transport.idle_scope = idle_scope - - with idle_scope: - # Drive via `serve_loop` (not `Server.run()`) so the - # manager's already-entered lifespan is reused - # rather than re-entered per session. - await serve_loop( - self.app, - read_stream, - write_stream, - lifespan_state=self._lifespan_state, - session_id=http_transport.mcp_session_id, - ) - - if idle_scope.cancelled_caught: - assert http_transport.mcp_session_id is not None - logger.info(f"Session {http_transport.mcp_session_id} idle timeout") - self._server_instances.pop(http_transport.mcp_session_id, None) - self._session_owners.pop(http_transport.mcp_session_id, None) - await http_transport.terminate() - except Exception: - logger.exception(f"Session {http_transport.mcp_session_id} crashed") - finally: - if ( # pragma: no branch - http_transport.mcp_session_id - and http_transport.mcp_session_id in self._server_instances - and not http_transport.is_terminated - ): - logger.info( - "Cleaning up crashed session " - f"{http_transport.mcp_session_id} from active instances." - ) - del self._server_instances[http_transport.mcp_session_id] - self._session_owners.pop(http_transport.mcp_session_id, None) - - # Assert task group is not None for type checking - assert self._task_group is not None - # Start the server task - await self._task_group.start(run_server) - - # Handle the HTTP request and return the response - await http_transport.handle_request(scope, receive, send) + http_transport = self._admit_session(requestor) + if http_transport is None: + logger.warning("Refusing to open a new session: %d sessions are already open", self.max_sessions) + await _error_response("Too many open sessions", 503, INTERNAL_ERROR)(scope, receive, send) + return + await self._serve_opening_request(http_transport, scope, receive, send) else: # Unknown or expired session ID - return 404 per MCP spec # TODO(L62): Align error code once spec clarifies # See: https://github.com/modelcontextprotocol/python-sdk/issues/1821 logger.info(f"Rejected request with unknown or expired session ID: {request_mcp_session_id[:64]}") - body = JSONRPCError( - jsonrpc="2.0", id=None, error=ErrorData(code=INVALID_REQUEST, message="Session not found") - ) - response = Response( - body.model_dump_json(by_alias=True, exclude_unset=True), status_code=404, media_type="application/json" - ) - await response(scope, receive, send) + await _error_response("Session not found", 404)(scope, receive, send) + + def _admit_session(self, requestor: AuthorizationContext | None) -> StreamableHTTPServerTransport | None: + """Register a new session for `requestor` and return its transport, or None at the session limit.""" + if self.max_sessions is not None and len(self._server_instances) >= self.max_sessions: + return None + http_transport = StreamableHTTPServerTransport( + mcp_session_id=uuid4().hex, + is_json_response_enabled=self.json_response, + event_store=self.event_store, # May be None (no resumability) + security_settings=self.security_settings, + retry_interval=self.retry_interval, + idle_timeout=self.session_idle_timeout, + ) + session_id = http_transport.mcp_session_id + assert session_id is not None + if requestor is not None: + self._session_owners[session_id] = requestor + self._server_instances[session_id] = http_transport + logger.info(f"Created new transport with session ID: {session_id}") + return http_transport + + async def _serve_opening_request( + self, http_transport: StreamableHTTPServerTransport, scope: Scope, receive: Receive, send: Send + ) -> None: + """Start the session's server task and let its transport answer the request that opens it. + + Without a session ID only an initialize request can succeed, so if this + one is refused, fails or is cancelled (or the session's server task + cannot even be started) nothing was established: the session is + discarded again rather than kept (with its server task) around. + """ + session_id = http_transport.mcp_session_id + assert session_id is not None + + async def run_server(*, task_status: TaskStatus[None] = anyio.TASK_STATUS_IGNORED) -> None: + async with http_transport.connect() as streams: + read_stream, write_stream = streams + task_status.started() + try: + # The transport cancels its idle scope once no request + # has been in flight for `session_idle_timeout`; that + # ends the loop and execution continues after the + # `with` block. Without a timeout there is nothing to fire. + idle_scope = http_transport.idle_scope + if idle_scope is None: + idle_scope = anyio.CancelScope() + with idle_scope: + # Drive via `serve_loop` (not `Server.run()`) so the + # manager's already-entered lifespan is reused + # rather than re-entered per session. + await serve_loop( + self.app, + read_stream, + write_stream, + lifespan_state=self._lifespan_state, + session_id=session_id, + ) + + if idle_scope.cancelled_caught: + logger.info(f"Session {session_id} idle timeout") + except Exception: + logger.exception(f"Session {session_id} crashed") + finally: + # However the session ended (client DELETE, idle + # timeout, crash), discard it. + await self._discard_session(session_id, http_transport) + + established = False + try: + assert self._task_group is not None + await self._task_group.start(run_server) + status = await _send_and_report_status(http_transport.handle_request, scope, receive, send) + established = status is not None and status < 400 + finally: + if not established: # pragma: no branch + await self._discard_session(session_id, http_transport) + + async def _discard_session(self, session_id: str, transport: StreamableHTTPServerTransport) -> None: + """Stop tracking the session and make sure its transport refuses anything that still reaches it. + + The session is forgotten first, before any await, so its ID answers 404 + from the moment this is called; terminating the transport is shielded so + it completes even while the caller is being cancelled. + """ + self._server_instances.pop(session_id, None) + self._session_owners.pop(session_id, None) + if not transport.is_terminated: + with anyio.CancelScope(shield=True): + await transport.terminate() + + +def _error_response(message: str, status_code: int, code: int = INVALID_REQUEST) -> Response: + """A JSON-RPC error body (no request id) with the given HTTP status.""" + body = JSONRPCError(jsonrpc="2.0", id=None, error=ErrorData(code=code, message=message)) + return Response( + body.model_dump_json(by_alias=True, exclude_unset=True), status_code=status_code, media_type="application/json" + ) + + +async def _send_and_report_status(app: ASGIApp, scope: Scope, receive: Receive, send: Send) -> int | None: + """Run `app` for one request and return the HTTP status it answered with (None if it sent no response).""" + status: int | None = None + + async def watch_status(message: Message) -> None: + nonlocal status + if message["type"] == "http.response.start": + status = message["status"] + await send(message) + + await app(scope, receive, watch_status) + return status class StreamableHTTPASGIApp: diff --git a/tests/docs_src/test_asgi.py b/tests/docs_src/test_asgi.py index ef237bc2..d241dc71 100644 --- a/tests/docs_src/test_asgi.py +++ b/tests/docs_src/test_asgi.py @@ -13,7 +13,7 @@ from starlette.routing import Mount, Route from docs_src.asgi import tutorial001, tutorial002, tutorial003, tutorial004, tutorial005, tutorial006 from mcp import Client -from mcp.server import MCPServer +from mcp.server import MCPServer, Server # See test_index.py for why this is a per-module mark and not a conftest hook. pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")] @@ -44,6 +44,8 @@ async def test_streamable_http_app_takes_runs_options_except_port() -> None: "event_store", "retry_interval", "max_request_body_size", + "session_idle_timeout", + "max_sessions", "transport_security", "host", } @@ -68,6 +70,18 @@ async def test_streamable_http_app_applies_the_configured_request_body_limit() - assert response.status_code == 413 +async def test_streamable_http_app_applies_the_configured_session_limits() -> None: + """The documented `session_idle_timeout` and `max_sessions` options reach the session manager, from + both the high-level and the low-level factory.""" + server = MCPServer("Notes") + server.streamable_http_app(session_idle_timeout=5, max_sessions=7) + assert (server.session_manager.session_idle_timeout, server.session_manager.max_sessions) == (5, 7) + + lowlevel = Server("Notes") + lowlevel.streamable_http_app(session_idle_timeout=None, max_sessions=None) + assert (lowlevel.session_manager.session_idle_timeout, lowlevel.session_manager.max_sessions) == (None, None) + + async def test_mounting_at_the_root_keeps_the_default_path() -> None: """tutorial002: `Mount("/")` plus the default `streamable_http_path` leaves the endpoint at `/mcp`.""" (mount,) = tutorial002.app.routes diff --git a/tests/docs_src/test_legacy_clients.py b/tests/docs_src/test_legacy_clients.py index 3018e4fc..0b2f1e08 100644 --- a/tests/docs_src/test_legacy_clients.py +++ b/tests/docs_src/test_legacy_clients.py @@ -53,6 +53,8 @@ def test_streamable_http_app_has_no_era_knob() -> None: "event_store", "retry_interval", "max_request_body_size", + "session_idle_timeout", + "max_sessions", "transport_security", "host", } @@ -76,6 +78,20 @@ async def test_a_legacy_session_is_minted_in_process_and_a_stray_session_id_is_a assert stray.status_code == 404 +def test_legacy_sessions_expire_and_are_capped_by_default() -> None: + """The session lifetime section: a session is closed after 30 idle minutes and each worker process + holds at most 10 000 of them, unless `run()` / `streamable_http_app()` say otherwise.""" + server = MCPServer("Bookshop") + server.streamable_http_app() + assert server.session_manager.session_idle_timeout == 30 * 60 + assert server.session_manager.max_sessions == 10_000 + + server = MCPServer("Bookshop") + server.streamable_http_app(session_idle_timeout=None, max_sessions=None) + assert server.session_manager.session_idle_timeout is None + assert server.session_manager.max_sessions is None + + async def test_stateless_http_never_mints_a_session() -> None: """The `stateless_http=True` section: the same legacy `initialize` no longer gets an `Mcp-Session-Id`.""" app = MCPServer("Bookshop").streamable_http_app(stateless_http=True) diff --git a/tests/interaction/transports/test_hosting_session.py b/tests/interaction/transports/test_hosting_session.py index 23c8da15..6aa2a921 100644 --- a/tests/interaction/transports/test_hosting_session.py +++ b/tests/interaction/transports/test_hosting_session.py @@ -107,23 +107,15 @@ async def test_delete_terminates_the_session_and_subsequent_requests_return_404( delete = await http.delete("/mcp", headers=base_headers(session_id=session_id)) assert delete.status_code == 200 - # The manager keeps the terminated transport registered, so the next request reaches the - # transport's own _terminated check rather than the manager's unknown-session path. - assert session_id in manager._server_instances + # The manager forgets a terminated session, so from then on the ID is simply unknown. + assert session_id not in manager._server_instances post = await http.post( "/mcp", json={"jsonrpc": "2.0", "id": 2, "method": "tools/list"}, headers=base_headers(session_id=session_id), ) assert (post.status_code, post.json()) == snapshot( - ( - 404, - { - "jsonrpc": "2.0", - "id": None, - "error": {"code": -32600, "message": "Not Found: Session has been terminated"}, - }, - ) + (404, {"jsonrpc": "2.0", "id": None, "error": {"code": -32600, "message": "Session not found"}}) ) diff --git a/tests/server/test_streamable_http_manager.py b/tests/server/test_streamable_http_manager.py index 1c0f88a6..1b8f9264 100644 --- a/tests/server/test_streamable_http_manager.py +++ b/tests/server/test_streamable_http_manager.py @@ -2,15 +2,28 @@ import json import logging -from collections.abc import Iterator -from typing import Any +import math +from collections.abc import AsyncIterator, Iterator +from contextlib import asynccontextmanager, contextmanager +from typing import Any, cast from unittest.mock import AsyncMock, patch import anyio import httpx2 import pytest -from mcp_types import INVALID_REQUEST, ListToolsResult, PaginatedRequestParams -from starlette.types import Message, Scope +from mcp_types import ( + INTERNAL_ERROR, + INVALID_REQUEST, + CallToolRequestParams, + CallToolResult, + ListToolsResult, + PaginatedRequestParams, + TextContent, +) +from mcp_types.version import LATEST_HANDSHAKE_VERSION +from starlette.applications import Starlette +from starlette.routing import Mount +from starlette.types import ASGIApp, Message, Receive, Scope, Send from mcp import Client from mcp.client.streamable_http import streamable_http_client @@ -18,7 +31,32 @@ from mcp.server import Server, ServerRequestContext, streamable_http_manager from mcp.server.auth.middleware.bearer_auth import AuthenticatedUser from mcp.server.auth.provider import AccessToken from mcp.server.streamable_http import MCP_SESSION_ID_HEADER, StreamableHTTPServerTransport -from mcp.server.streamable_http_manager import DEFAULT_MAX_REQUEST_BODY_SIZE, StreamableHTTPSessionManager +from mcp.server.streamable_http_manager import ( + DEFAULT_MAX_REQUEST_BODY_SIZE, + DEFAULT_MAX_SESSIONS, + DEFAULT_SESSION_IDLE_TIMEOUT, + StreamableHTTPSessionManager, +) +from tests.interaction.transports import StreamingASGITransport + +# The in-process app is mounted at this origin purely so URLs are well-formed; nothing listens here. +BASE_URL = "http://127.0.0.1:8000" + +_JSON_HEADERS = {"accept": "application/json, text/event-stream", "content-type": "application/json"} + +_INITIALIZE_BODY = json.dumps( + { + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "protocolVersion": LATEST_HANDSHAKE_VERSION, + "capabilities": {}, + "clientInfo": {"name": "test", "version": "0"}, + }, + } +).encode() +"""A wire-level initialize request: the only request that may open a session.""" @pytest.mark.anyio @@ -286,19 +324,7 @@ async def test_stateless_requests_memory_cleanup(): app = Server("test-stateless-real-cleanup") manager = StreamableHTTPSessionManager(app=app, stateless=True) - # Track created transport instances - created_transports: list[StreamableHTTPServerTransport] = [] - - # Patch StreamableHTTPServerTransport constructor to track instances - - original_constructor = StreamableHTTPServerTransport - - def track_transport(*args: Any, **kwargs: Any) -> StreamableHTTPServerTransport: - transport = original_constructor(*args, **kwargs) - created_transports.append(transport) - return transport - - with patch.object(streamable_http_manager, "StreamableHTTPServerTransport", side_effect=track_transport): + with _created_transports() as created_transports: async with manager.run(): # Send a simple request sent_messages: list[Message] = [] @@ -420,50 +446,57 @@ class _IdleTimeoutObserver(logging.Handler): self.reaped.set() -@pytest.mark.anyio -async def test_idle_session_is_reaped(caplog: pytest.LogCaptureFixture, request: pytest.FixtureRequest): - """After idle timeout fires, the session returns 404.""" - app = Server("test-idle-reap") - manager = StreamableHTTPSessionManager(app=app, session_idle_timeout=0.05) +def _observe_idle_timeout(caplog: pytest.LogCaptureFixture, request: pytest.FixtureRequest) -> _IdleTimeoutObserver: + """Install an observer for the manager's "idle timeout" log record for the rest of the test. - # The reap is observed through the manager's own "idle timeout" log record: the manager pops - # the session synchronously after emitting it, before its next await, so a waiter woken by - # the record always finds the session gone. caplog.set_level enables INFO so it is created. + The manager pops the session synchronously after emitting that record, before its next await, + so a waiter woken by it always finds the session gone. caplog.set_level enables INFO so the + record is created. + """ observer = _IdleTimeoutObserver() manager_logger = logging.getLogger(streamable_http_manager.__name__) manager_logger.addHandler(observer) request.addfinalizer(lambda: manager_logger.removeHandler(observer)) caplog.set_level(logging.INFO, logger=streamable_http_manager.__name__) + return observer + + +@contextmanager +def _created_transports() -> Iterator[list[StreamableHTTPServerTransport]]: + """Collect every transport a session manager creates while the context is open.""" + created: list[StreamableHTTPServerTransport] = [] + + def create(*args: Any, **kwargs: Any) -> StreamableHTTPServerTransport: + transport = StreamableHTTPServerTransport(*args, **kwargs) + created.append(transport) + return transport + + with patch.object(streamable_http_manager, "StreamableHTTPServerTransport", side_effect=create): + yield created + + +@asynccontextmanager +async def _served( + manager: StreamableHTTPSessionManager, endpoint: ASGIApp | None = None +) -> AsyncIterator[httpx2.AsyncClient]: + """Run `manager` behind an in-process HTTP client whose responses stream as they are produced. + + `endpoint` is the ASGI app mounted for it; by default the manager itself. + """ + app = Starlette(routes=[Mount("/", app=endpoint or manager.handle_request)]) + async with manager.run(), httpx2.AsyncClient(transport=StreamingASGITransport(app), base_url=BASE_URL) as http: + yield http + + +@pytest.mark.anyio +async def test_idle_session_is_reaped(caplog: pytest.LogCaptureFixture, request: pytest.FixtureRequest): + """After idle timeout fires, the session returns 404.""" + app = Server("test-idle-reap") + manager = StreamableHTTPSessionManager(app=app, session_idle_timeout=0.05) + observer = _observe_idle_timeout(caplog, request) async with manager.run(): - sent_messages: list[Message] = [] - - async def mock_send(message: Message): - sent_messages.append(message) - - scope = { - "type": "http", - "method": "POST", - "path": "/mcp", - "headers": [(b"content-type", b"application/json")], - } - - async def mock_receive(): - return {"type": "http.request", "body": b"", "more_body": False} - - await manager.handle_request(scope, mock_receive, mock_send) - - session_id = None - for msg in sent_messages: # pragma: no branch - if msg["type"] == "http.response.start": # pragma: no branch - for header_name, header_value in msg.get("headers", []): # pragma: no branch - if header_name.decode().lower() == MCP_SESSION_ID_HEADER.lower(): - session_id = header_value.decode() - break - if session_id: # pragma: no branch - break - - assert session_id is not None, "Session ID not found in response headers" + session_id = await _open_session(manager, None) # Wait for the 50ms idle timeout to fire and the session to be unregistered. Re-requesting # the session to poll for the 404 would push its idle deadline forward and keep it alive. @@ -471,41 +504,402 @@ async def test_idle_session_is_reaped(caplog: pytest.LogCaptureFixture, request: await observer.reaped.wait() # Verify via public API: old session ID now returns 404 - response_messages: list[Message] = [] + assert await _request_session(manager, session_id, None) == 404 - async def capture_send(message: Message): - response_messages.append(message) - scope_with_session = { - "type": "http", - "method": "POST", - "path": "/mcp", - "headers": [ - (b"content-type", b"application/json"), - (b"mcp-session-id", session_id.encode()), - ], +@pytest.mark.anyio +async def test_request_in_flight_holds_the_session_open( + caplog: pytest.LogCaptureFixture, request: pytest.FixtureRequest +) -> None: + """A session does not expire while one of its requests is still being served, however long that takes; + the idle period is counted from the moment its last request completes.""" + tool_started = anyio.Event() + release_tool = anyio.Event() + + async def handle_call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult: + tool_started.set() + await release_tool.wait() + return CallToolResult(content=[TextContent(type="text", text="done")]) + + app = Server("test-in-flight", on_call_tool=handle_call_tool) + manager = StreamableHTTPSessionManager(app=app, session_idle_timeout=30) + observer = _observe_idle_timeout(caplog, request) + + async with _served(manager) as http: + initialize = await http.post("/mcp", content=_INITIALIZE_BODY, headers=_JSON_HEADERS) + assert initialize.status_code == 200 + session_id = initialize.headers[MCP_SESSION_ID_HEADER] + transport = manager._server_instances[session_id] + session_headers = _JSON_HEADERS | {MCP_SESSION_ID_HEADER: session_id} + call_tool_body: dict[str, Any] = { + "jsonrpc": "2.0", + "id": 2, + "method": "tools/call", + "params": {"name": "slow", "arguments": {}}, + } + responses: list[httpx2.Response] = [] + + async def call_tool() -> None: + responses.append(await http.post("/mcp", json=call_tool_body, headers=session_headers)) + + async with anyio.create_task_group() as tg: + tg.start_soon(call_tool) + with anyio.fail_after(5): + await tool_started.wait() + # While the call is being served the idle countdown is suspended. + assert transport.idle_scope is not None and transport.idle_scope.deadline == math.inf + # From here on a short idle period, counted from the moment the call completes. + transport._idle_timeout = 0.05 + release_tool.set() + + assert responses[0].status_code == 200 + assert '"done"' in responses[0].text + + # Nothing is in flight any more, so the idle period now runs out. + with anyio.fail_after(5): + await observer.reaped.wait() + assert session_id not in manager._server_instances + assert transport.is_terminated + followup = await http.post("/mcp", json={"jsonrpc": "2.0", "id": 3, "method": "ping"}, headers=session_headers) + assert followup.status_code == 404 + + +@pytest.mark.anyio +async def test_open_event_stream_holds_the_session_open( + caplog: pytest.LogCaptureFixture, request: pytest.FixtureRequest +) -> None: + """A client listening on the session's GET stream keeps the session, even if it sends nothing; + once the stream closes the idle period runs out and the session is gone.""" + manager = StreamableHTTPSessionManager(app=Server("test-get-stream"), session_idle_timeout=30) + observer = _observe_idle_timeout(caplog, request) + + async with _served(manager) as http: + initialize = await http.post("/mcp", content=_INITIALIZE_BODY, headers=_JSON_HEADERS) + assert initialize.status_code == 200 + session_id = initialize.headers[MCP_SESSION_ID_HEADER] + session_headers = _JSON_HEADERS | {MCP_SESSION_ID_HEADER: session_id} + + get_headers = {"accept": "text/event-stream", MCP_SESSION_ID_HEADER: session_id} + async with http.stream("GET", "/mcp", headers=get_headers) as stream: + assert stream.status_code == 200 + # The stream has been answered, so it is in flight: the idle countdown is suspended. + transport = manager._server_instances[session_id] + assert transport.idle_scope is not None and transport.idle_scope.deadline == math.inf + # From here on a short idle period, counted from the moment the stream closes. + transport._idle_timeout = 0.05 + + with anyio.fail_after(5): + await observer.reaped.wait() + assert session_id not in manager._server_instances + assert transport.is_terminated + followup = await http.post("/mcp", json={"jsonrpc": "2.0", "id": 2, "method": "ping"}, headers=session_headers) + assert followup.status_code == 404 + + +@pytest.mark.anyio +async def test_request_completing_under_an_open_event_stream_does_not_start_the_countdown( + caplog: pytest.LogCaptureFixture, request: pytest.FixtureRequest +) -> None: + """A request that completes while the session's GET stream is still open does not start the idle + period: the stream is still in flight, so the countdown only begins once it closes too.""" + manager = StreamableHTTPSessionManager(app=Server("test-get-stream-and-post"), session_idle_timeout=30) + observer = _observe_idle_timeout(caplog, request) + session_post_served = anyio.Event() + + async def endpoint(scope: Scope, receive: Receive, send: Send) -> None: + # Report once a POST for the open session has been served to the end, + # in-flight bookkeeping included. + await manager.handle_request(scope, receive, send) + if scope["method"] == "POST" and MCP_SESSION_ID_HEADER.encode() in dict(scope["headers"]): + session_post_served.set() + + async with _served(manager, endpoint) as http: + initialize = await http.post("/mcp", content=_INITIALIZE_BODY, headers=_JSON_HEADERS) + assert initialize.status_code == 200 + session_id = initialize.headers[MCP_SESSION_ID_HEADER] + session_headers = _JSON_HEADERS | {MCP_SESSION_ID_HEADER: session_id} + + get_headers = {"accept": "text/event-stream", MCP_SESSION_ID_HEADER: session_id} + async with http.stream("GET", "/mcp", headers=get_headers) as stream: + assert stream.status_code == 200 + transport = manager._server_instances[session_id] + assert transport.idle_scope is not None and transport.idle_scope.deadline == math.inf + ping = await http.post("/mcp", json={"jsonrpc": "2.0", "id": 2, "method": "ping"}, headers=session_headers) + assert ping.status_code == 200 + with anyio.fail_after(5): + await session_post_served.wait() + # The ping has completed, but the open stream still suspends the idle countdown. + assert transport.idle_scope.deadline == math.inf + # From here on a short idle period, counted from the moment the stream closes. + transport._idle_timeout = 0.05 + + with anyio.fail_after(5): + await observer.reaped.wait() + assert session_id not in manager._server_instances + assert transport.is_terminated + followup = await http.post("/mcp", json={"jsonrpc": "2.0", "id": 3, "method": "ping"}, headers=session_headers) + assert followup.status_code == 404 + + +def test_session_idle_timeout_defaults_to_thirty_minutes() -> None: + """Stateful sessions expire after 30 minutes without a request in flight unless configured otherwise.""" + manager = StreamableHTTPSessionManager(app=Server("test")) + assert manager.session_idle_timeout == DEFAULT_SESSION_IDLE_TIMEOUT == 30 * 60 + + +@pytest.mark.parametrize("session_idle_timeout", [0, -1, float("inf"), float("nan")]) +def test_session_idle_timeout_rejects_invalid_values(session_idle_timeout: float) -> None: + """The idle timeout is a positive, finite number of seconds, or None for sessions that never expire.""" + with pytest.raises(ValueError) as exc_info: + StreamableHTTPSessionManager(app=Server("test"), session_idle_timeout=session_idle_timeout) + assert str(exc_info.value) == "session_idle_timeout must be a positive, finite number of seconds" + + +@pytest.mark.anyio +async def test_session_idle_timeout_is_unused_in_stateless_mode() -> None: + """Stateless mode keeps no sessions, so the idle timeout is accepted and simply has nothing to expire.""" + manager = StreamableHTTPSessionManager(app=Server("test"), session_idle_timeout=30, stateless=True) + async with manager.run(): + response_start, _ = await _call(manager, _request_scope(), _INITIALIZE_BODY) + assert response_start["status"] == 200 + assert manager._server_instances == {} + + +@pytest.mark.anyio +@pytest.mark.parametrize("session_idle_timeout", [DEFAULT_SESSION_IDLE_TIMEOUT, None]) +async def test_deleted_session_is_forgotten(session_idle_timeout: float | None) -> None: + """A client DELETE ends the session and the manager stops tracking it; the ID is unknown afterwards.""" + manager = StreamableHTTPSessionManager(app=Server("test-delete"), session_idle_timeout=session_idle_timeout) + async with manager.run(): + session_id = await _open_session(manager, None) + assert session_id in manager._server_instances + + assert await _request_session(manager, session_id, None, method="DELETE") == 200 + assert session_id not in manager._server_instances + response_start, response_body = await _call(manager, _request_scope(session_id=session_id)) + assert response_start["status"] == 404 + assert json.loads(response_body) == { + "jsonrpc": "2.0", + "id": None, + "error": {"code": INVALID_REQUEST, "message": "Session not found"}, } - await manager.handle_request(scope_with_session, mock_receive, capture_send) - response_start = next( - (msg for msg in response_messages if msg["type"] == "http.response.start"), - None, - ) - assert response_start is not None - assert response_start["status"] == 404 +@pytest.mark.anyio +async def test_opening_request_that_fails_leaves_no_session() -> None: + """If serving the request that would open a session raises, the provisional session is discarded + there and then rather than left registered with its server task running.""" + manager = StreamableHTTPSessionManager(app=Server("test-failed-open")) + with _created_transports() as transports: + async with manager.run(): + with ( + patch.object( + StreamableHTTPServerTransport, "handle_request", AsyncMock(side_effect=RuntimeError("boom")) + ), + pytest.raises(RuntimeError, match="boom"), + anyio.fail_after(5), + ): + await _call(manager, _request_scope(), _INITIALIZE_BODY) + assert manager._server_instances == {} + assert manager._session_owners == {} + (transport,) = transports + assert transport.is_terminated -def test_session_idle_timeout_rejects_non_positive(): - with pytest.raises(ValueError, match="positive number"): - StreamableHTTPSessionManager(app=Server("test"), session_idle_timeout=-1) - with pytest.raises(ValueError, match="positive number"): - StreamableHTTPSessionManager(app=Server("test"), session_idle_timeout=0) +@pytest.mark.anyio +async def test_opening_request_that_is_cancelled_leaves_no_session() -> None: + """If the request that would open a session is cancelled while it is being served (the client went + away), the provisional session is discarded rather than left registered.""" + manager = StreamableHTTPSessionManager(app=Server("test-cancelled-open")) + entered = anyio.Event() + + async def hang(self: StreamableHTTPServerTransport, scope: Scope, receive: Receive, send: Send) -> None: + entered.set() + await anyio.sleep_forever() + + opening_request = anyio.CancelScope() + + async def open_session() -> None: + with opening_request: + await _call(manager, _request_scope(), _INITIALIZE_BODY) + + with _created_transports() as transports, patch.object(StreamableHTTPServerTransport, "handle_request", hang): + async with manager.run(): + async with anyio.create_task_group() as tg: + tg.start_soon(open_session) + with anyio.fail_after(5): + await entered.wait() + assert len(manager._server_instances) == 1 + opening_request.cancel() + assert manager._server_instances == {} + assert manager._session_owners == {} + (transport,) = transports + assert transport.is_terminated -def test_session_idle_timeout_rejects_stateless(): - with pytest.raises(RuntimeError, match="not supported in stateless"): - StreamableHTTPSessionManager(app=Server("test"), session_idle_timeout=30, stateless=True) +@pytest.mark.anyio +async def test_opening_request_whose_session_task_cannot_start_leaves_no_session() -> None: + """If the server task for a would-be session cannot be started, the provisional session is discarded + (forgotten, its transport terminated) rather than left registered without anything serving it.""" + manager = StreamableHTTPSessionManager(app=Server("test-unstartable-open")) + + @asynccontextmanager + async def connect_that_fails(self: StreamableHTTPServerTransport) -> AsyncIterator[None]: + raise RuntimeError("boom") + yield + + with _created_transports() as transports: + async with manager.run(): + with ( + patch.object(StreamableHTTPServerTransport, "connect", connect_that_fails), + pytest.raises(RuntimeError, match="boom"), + anyio.fail_after(5), + ): + await _call(manager, _request_scope(), _INITIALIZE_BODY) + assert manager._server_instances == {} + assert manager._session_owners == {} + (transport,) = transports + assert transport.is_terminated + + +@pytest.mark.anyio +async def test_stateless_request_that_is_cancelled_still_terminates_its_transport() -> None: + """If a stateless request is cancelled while it is being served (the client went away), its transport + is terminated all the same, which is what ends the per-request server task.""" + manager = StreamableHTTPSessionManager(app=Server("test-stateless-cancelled"), stateless=True) + entered = anyio.Event() + + async def hang(self: StreamableHTTPServerTransport, scope: Scope, receive: Receive, send: Send) -> None: + entered.set() + await anyio.sleep_forever() + + stateless_request = anyio.CancelScope() + + async def make_request() -> None: + with stateless_request: + await _call(manager, _request_scope(), _INITIALIZE_BODY) + + with _created_transports() as transports, patch.object(StreamableHTTPServerTransport, "handle_request", hang): + async with manager.run(): + async with anyio.create_task_group() as tg: + tg.start_soon(make_request) + with anyio.fail_after(5): + await entered.wait() + stateless_request.cancel() + (transport,) = transports + assert transport.is_terminated + + +@pytest.mark.anyio +@pytest.mark.parametrize( + ("method", "headers", "body", "expected_status"), + [ + ("POST", _JSON_HEADERS, b'{"jsonrpc": "2.0", "id": 1, "method": "tools/list"}', 400), + ("POST", _JSON_HEADERS, b'{"jsonrpc": "2.0", "method": "notifications/initialized"}', 400), + ("POST", _JSON_HEADERS, b"{not json", 400), + ("POST", _JSON_HEADERS | {"accept": "text/plain"}, _INITIALIZE_BODY, 406), + ("GET", {"accept": "text/event-stream"}, b"", 400), + ("DELETE", _JSON_HEADERS, b"", 400), + ("PATCH", _JSON_HEADERS, b"", 405), + ], + ids=[ + "non-initialize-request", + "notification", + "malformed-json", + "unacceptable-accept-header", + "get-without-session", + "delete-without-session", + "unsupported-method", + ], +) +async def test_refused_opening_request_leaves_no_session( + method: str, headers: dict[str, str], body: bytes, expected_status: int +) -> None: + """Only an accepted initialize opens a session: a request without a session ID that is answered with an + error leaves nothing registered once the manager has answered it.""" + manager = StreamableHTTPSessionManager(app=Server("test-refused")) + scope: Scope = { + "type": "http", + "method": method, + "path": "/mcp", + "headers": [(name.encode(), value.encode()) for name, value in headers.items()], + } + with _created_transports() as transports: + async with manager.run(): + response_start, _ = await _call(manager, scope, body) + assert response_start["status"] == expected_status + assert manager._server_instances == {} + assert manager._session_owners == {} + (transport,) = transports + assert transport.is_terminated + + +@pytest.mark.anyio +async def test_new_session_is_refused_at_max_sessions() -> None: + """At the session limit a further initialize is answered 503 and opens nothing; room frees up as + sessions end.""" + manager = StreamableHTTPSessionManager(app=Server("test-cap"), max_sessions=1) + async with manager.run(): + first = await _open_session(manager, None) + + response_start, response_body = await _call(manager, _request_scope(), _INITIALIZE_BODY) + assert response_start["status"] == 503 + assert json.loads(response_body) == { + "jsonrpc": "2.0", + "id": None, + "error": {"code": INTERNAL_ERROR, "message": "Too many open sessions"}, + } + assert list(manager._server_instances) == [first] + + assert await _request_session(manager, first, None, method="DELETE") == 200 + second = await _open_session(manager, None) + assert list(manager._server_instances) == [second] + + +@pytest.mark.anyio +async def test_client_that_is_slow_to_send_its_opening_request_does_not_hold_up_others() -> None: + """While one client has yet to finish sending the request that would open its session, another + client can still open one.""" + manager = StreamableHTTPSessionManager(app=Server("test-slow-open")) + body_awaited = anyio.Event() + + async def stall() -> None: + # This client has sent its headers but never finishes sending the body. + body_awaited.set() + await anyio.sleep_forever() + + async def discard(message: Message) -> None: ... + + slow_client = anyio.CancelScope() + + async def open_slowly() -> None: + with slow_client: + await manager.handle_request(_request_scope(), cast(Receive, stall), discard) + + session_id: str | None = None + async with manager.run(): + async with anyio.create_task_group() as tg: + tg.start_soon(open_slowly) + with anyio.fail_after(5): + await body_awaited.wait() + session_id = await _open_session(manager, None) + slow_client.cancel() + assert session_id is not None + assert list(manager._server_instances) == [session_id] + + +def test_max_sessions_defaults_to_ten_thousand() -> None: + """A manager holds at most 10 000 concurrent stateful sessions unless configured otherwise.""" + manager = StreamableHTTPSessionManager(app=Server("test")) + assert manager.max_sessions == DEFAULT_MAX_SESSIONS == 10_000 + assert StreamableHTTPSessionManager(app=Server("test"), max_sessions=None).max_sessions is None + + +@pytest.mark.parametrize("max_sessions", [0, -1]) +def test_max_sessions_rejects_non_positive_values(max_sessions: int) -> None: + with pytest.raises(ValueError) as exc_info: + StreamableHTTPSessionManager(app=Server("test"), max_sessions=max_sessions) + assert str(exc_info.value) == "max_sessions must be a positive number of sessions or None" def _user(client_id: str, subject: str | None = None, issuer: str | None = None) -> AuthenticatedUser: @@ -535,19 +929,33 @@ def _request_scope( return scope -async def _open_session(manager: StreamableHTTPSessionManager, user: AuthenticatedUser | None) -> str: - """Create a new session as `user` and return its session ID.""" +async def _call(manager: StreamableHTTPSessionManager, scope: Scope, body: bytes = b"") -> tuple[Message, bytes]: + """Drive one request through the manager in process; return its `http.response.start` message and body.""" sent_messages: list[Message] = [] + body_delivered = False - async def mock_send(message: Message) -> None: + async def send(message: Message) -> None: sent_messages.append(message) - async def mock_receive() -> Message: - return {"type": "http.request", "body": b"", "more_body": False} - - await manager.handle_request(_request_scope(user=user), mock_receive, mock_send) + async def receive() -> Message: + # Deliver the body once, then block like a client holding the connection + # open; a streaming response ends when the server closes it. + nonlocal body_delivered + if body_delivered: + await anyio.sleep_forever() + body_delivered = True + return {"type": "http.request", "body": body, "more_body": False} + await manager.handle_request(scope, receive, send) response_start = next(msg for msg in sent_messages if msg["type"] == "http.response.start") + response_body = b"".join(msg.get("body", b"") for msg in sent_messages if msg["type"] == "http.response.body") + return response_start, response_body + + +async def _open_session(manager: StreamableHTTPSessionManager, user: AuthenticatedUser | None) -> str: + """Create a new session as `user` with an initialize request and return its session ID.""" + response_start, _ = await _call(manager, _request_scope(user=user), _INITIALIZE_BODY) + assert response_start["status"] == 200 headers = dict(response_start.get("headers", [])) return headers[MCP_SESSION_ID_HEADER.encode()].decode() @@ -556,26 +964,14 @@ async def _request_session( manager: StreamableHTTPSessionManager, session_id: str, user: AuthenticatedUser | None, method: str = "POST" ) -> int: """Send a request for an existing session as `user` and return the response status.""" - sent_messages: list[Message] = [] - - async def mock_send(message: Message) -> None: - sent_messages.append(message) - - async def mock_receive() -> Message: - return {"type": "http.request", "body": b"", "more_body": False} - - await manager.handle_request( - _request_scope(session_id=session_id, user=user, method=method), mock_receive, mock_send - ) - - response_start = next(msg for msg in sent_messages if msg["type"] == "http.response.start") + response_start, _ = await _call(manager, _request_scope(session_id=session_id, user=user, method=method)) return response_start["status"] @pytest.fixture async def manager_with_live_session(): - """A running manager around a real `Server`. Sessions remain registered until - `manager.run()` exits because `Server.run` blocks waiting for an initialize message.""" + """A running manager around a real `Server`. Sessions are opened with a real initialize and stay + registered until `manager.run()` exits because nothing in these tests ends them.""" manager = StreamableHTTPSessionManager(app=Server("test-session-credentials")) async with manager.run(): yield manager diff --git a/tests/server/test_streamable_http_router.py b/tests/server/test_streamable_http_router.py index 07aa0634..0c5796c1 100644 --- a/tests/server/test_streamable_http_router.py +++ b/tests/server/test_streamable_http_router.py @@ -141,3 +141,19 @@ async def test_json_post_answers_500_when_session_terminates_mid_request() -> No assert post.sent[0]["type"] == "http.response.start" assert post.sent[0]["status"] == 500 + + +@pytest.mark.anyio +async def test_terminated_transport_answers_404() -> None: + """A request that still reaches a transport after its session was terminated is answered 404.""" + transport = StreamableHTTPServerTransport(mcp_session_id="sid") + post = _AsgiPost( + b'{"jsonrpc":"2.0","id":"req-1","method":"ping"}', + [(b"accept", b"application/json, text/event-stream"), (b"content-type", b"application/json")], + ) + async with transport.connect(): + await transport.terminate() + await transport.handle_request(post.scope, post.receive, post.send) + + assert post.sent[0]["type"] == "http.response.start" + assert post.sent[0]["status"] == 404 diff --git a/tests/shared/test_streamable_http.py b/tests/shared/test_streamable_http.py index aeef25a2..2e678a5c 100644 --- a/tests/shared/test_streamable_http.py +++ b/tests/shared/test_streamable_http.py @@ -7,8 +7,10 @@ entirely in process. from __future__ import annotations as _annotations import json +import logging import time from collections.abc import AsyncIterator +from concurrent.futures import ThreadPoolExecutor from contextlib import asynccontextmanager from dataclasses import dataclass, field from typing import Any @@ -599,6 +601,97 @@ def test_streamable_http_transport_init_validation() -> None: StreamableHTTPServerTransport(mcp_session_id="test\n") +@pytest.mark.parametrize("idle_timeout", [0, -1, float("inf"), float("nan")]) +def test_streamable_http_transport_rejects_invalid_idle_timeout(idle_timeout: float) -> None: + """A transport's idle timeout must be a positive, finite number of seconds; without one it never expires.""" + with pytest.raises(ValueError) as exc_info: + StreamableHTTPServerTransport(mcp_session_id="valid-id", idle_timeout=idle_timeout) + assert str(exc_info.value) == "idle_timeout must be a positive, finite number of seconds" + assert StreamableHTTPServerTransport(mcp_session_id="valid-id").idle_scope is None + + +def test_streamable_http_transport_with_idle_timeout_can_be_created_outside_an_event_loop() -> None: + """The idle scope is only created once connect() is entered, so a transport with a timeout can be + constructed without a running event loop.""" + # A bare thread has no async context; this one does, courtesy of the suite's shared runner. + with ThreadPoolExecutor(max_workers=1) as pool: + transport = pool.submit(StreamableHTTPServerTransport, mcp_session_id="valid-id", idle_timeout=5).result() + assert transport.idle_scope is None + + +@pytest.mark.anyio +async def test_streamable_http_transport_creates_its_idle_scope_on_connect() -> None: + """Entering connect() creates the idle scope the host enters around the session's message loop.""" + transport = StreamableHTTPServerTransport(mcp_session_id="valid-id", idle_timeout=5) + async with transport.connect(): + assert isinstance(transport.idle_scope, anyio.CancelScope) + await transport.terminate() + + +async def _post_to_transport(transport: StreamableHTTPServerTransport, body: dict[str, Any]) -> int: + """POST `body` straight to `transport` in process, as a client that then holds the connection open, + and return the status it answered with.""" + assert transport.mcp_session_id is not None + scope: Scope = { + "type": "http", + "method": "POST", + "path": "/mcp", + "query_string": b"", + "headers": [ + (b"content-type", b"application/json"), + (b"accept", b"application/json, text/event-stream"), + (MCP_SESSION_ID_HEADER.encode(), transport.mcp_session_id.encode()), + ], + } + sent: list[Message] = [] + + async def send(message: Message) -> None: + sent.append(message) + + request_body, incoming = anyio.create_memory_object_stream[Message](1) + async with request_body, incoming: + await request_body.send({"type": "http.request", "body": json.dumps(body).encode(), "more_body": False}) + with anyio.fail_after(5): + await transport.handle_request(scope, incoming.receive, send) + return next(message["status"] for message in sent if message["type"] == "http.response.start") + + +@pytest.mark.anyio +async def test_transport_whose_idle_period_ran_out_answers_as_terminated() -> None: + """Once the idle scope has fired, a request that still reaches the transport is answered 404 and the + transport is terminated, instead of being dispatched into the message loop the host is leaving.""" + transport = StreamableHTTPServerTransport(mcp_session_id="valid-id", idle_timeout=5) + ping = {"jsonrpc": "2.0", "id": 1, "method": "ping"} + async with transport.connect(): + assert transport.idle_scope is not None + # Exactly what the scope's deadline passing does. + transport.idle_scope.cancel() + + assert await _post_to_transport(transport, ping) == 404 + assert transport.is_terminated + assert await _post_to_transport(transport, ping) == 404 + + +@pytest.mark.anyio +async def test_transport_reports_stream_closure_when_host_exits_without_terminating( + caplog: pytest.LogCaptureFixture, +) -> None: + """A host that leaves connect() without terminating the transport closes the streams under the + message router, which reports it rather than passing it off as a client disconnect.""" + caplog.set_level(logging.ERROR, logger="mcp.server.streamable_http") + transport = StreamableHTTPServerTransport(mcp_session_id="valid-id") + + async with transport.connect(): + pass + + assert not transport.is_terminated + assert [ + record.getMessage() + for record in caplog.records + if record.name == "mcp.server.streamable_http" and record.levelno == logging.ERROR + ] == ["Unexpected closure of read stream in message router"] + + @pytest.mark.anyio async def test_session_termination(basic_app: Starlette) -> None: """DELETE terminates the session, after which requests for it return 404.""" @@ -639,7 +732,7 @@ async def test_session_termination(basic_app: Starlette) -> None: json={"jsonrpc": "2.0", "method": "ping", "id": 2}, ) assert response.status_code == 404 - assert "Session has been terminated" in response.text + assert response.json()["error"]["message"] == "Session not found" @pytest.mark.anyio @@ -1048,7 +1141,7 @@ async def test_streamable_http_client_session_termination(basic_app: Starlette) with pytest.raises(MCPError) as exc_info: # pragma: no branch await session.list_tools() assert exc_info.value.error.code == INVALID_REQUEST - assert "terminated" in exc_info.value.error.message.lower() + assert exc_info.value.error.message == "Session not found" @pytest.mark.anyio @@ -1111,7 +1204,7 @@ async def test_streamable_http_client_session_termination_204( with pytest.raises(MCPError) as exc_info: # pragma: no branch await session.list_tools() assert exc_info.value.error.code == INVALID_REQUEST - assert "terminated" in exc_info.value.error.message.lower() + assert exc_info.value.error.message == "Session not found" @pytest.mark.anyio From d060b36e1d095ef6e93e07ba5d59bb69b2ad449a Mon Sep 17 00:00:00 2001 From: Max <224885523+maxisbey@users.noreply.github.com> Date: Wed, 2 Sep 2026 16:51:22 +0100 Subject: [PATCH 05/20] Validate the authorization server metadata issuer on every discovery path (#3398) --- docs/client/oauth-clients.md | 7 +- docs/migration.md | 6 +- docs_src/oauth_clients/tutorial002.py | 1 + .../auth/extensions/client_credentials.py | 67 +++- src/mcp/client/auth/oauth2.py | 229 ++++++------ src/mcp/client/auth/utils.py | 18 +- .../extensions/test_client_credentials.py | 209 ++++++++++- tests/client/test_auth.py | 347 +++++++++++++++++- tests/docs_src/test_oauth_clients.py | 9 + 9 files changed, 759 insertions(+), 134 deletions(-) diff --git a/docs/client/oauth-clients.md b/docs/client/oauth-clients.md index cd7de356..3954fd53 100644 --- a/docs/client/oauth-clients.md +++ b/docs/client/oauth-clients.md @@ -76,7 +76,7 @@ Look at `main()`. The provider goes on the **httpx2 client**, the httpx2 client The first time `Client` sends a request, the server answers `401`. The provider takes over: -1. **Discovery.** It reads the `WWW-Authenticate` header, fetches the server's Protected Resource Metadata from `/.well-known/oauth-protected-resource`, learns which authorization server protects this resource, and fetches *that* server's metadata. +1. **Discovery.** It reads the `WWW-Authenticate` header, fetches the server's Protected Resource Metadata from `/.well-known/oauth-protected-resource`, learns which authorization server protects this resource, and fetches *that* server's metadata. (An older server that publishes no resource metadata is asked for authorization server metadata at its own origin instead.) Either way the metadata must name, as its `issuer`, the server it was fetched for; anything else is refused. 2. **Registration.** Nothing in storage? It registers you dynamically with your `OAuthClientMetadata` and stores the result. 3. **Authorization.** It generates the PKCE pair and a `state`, builds the authorization URL, awaits your `redirect_handler`, then awaits your `callback_handler` for the code. 4. **Exchange.** It trades the code for an `OAuthToken`, stores it, and replays your original request with `Authorization: Bearer ...`. @@ -105,13 +105,14 @@ A nightly job, a CI step, another service. There is no browser and nobody to cli `ClientCredentialsOAuthProvider` is the same `httpx2.Auth`, minus the human: -```python title="client.py" hl_lines="4 27-33" +```python title="client.py" hl_lines="4 27-34" --8<-- "docs_src/oauth_clients/tutorial002.py" ``` What changed: * No `OAuthClientMetadata`, no handlers. You pass `client_id` and `client_secret`; the provider builds a minimal `client_credentials` registration around them and skips dynamic registration entirely. +* `issuer` names the authorization server that issued those credentials; use the `issuer` value its `/.well-known/oauth-authorization-server` document returns. Discovery still runs as above, but token requests are only ever built from metadata for *that* issuer; if the MCP server points anywhere else, the flow stops with an `OAuthFlowError` instead. Leave it out and the provider uses whichever authorization server discovery finds. * `scope` is a space-separated string, the OAuth wire format. * Everything downstream is identical: the same `TokenStorage`, the same `httpx2.AsyncClient(auth=...)`, the same `streamable_http_client`. @@ -124,7 +125,7 @@ By default the secret travels as HTTP Basic auth on the token request (`client_s One more provider lives in `mcp.client.auth.extensions.client_credentials`: **`PrivateKeyJWTOAuthProvider`**, for clients that authenticate with a JWT instead of a shared secret (`private_key_jwt`, the key-pair and workload-identity flavour). It follows - the same pattern: construct one, put it on `auth=`. The same module ships + the same pattern: construct one (it takes the same optional `issuer`), put it on `auth=`. The same module ships `SignedJWTParameters` and `static_assertion_provider`, two helpers that build its assertion. There is one more no-human situation: the client belongs to an enterprise whose identity provider, not the user, decides which MCP servers it may reach. That is a different grant with its own trust model and its own page, **[Identity assertion](identity-assertion.md)**. diff --git a/docs/migration.md b/docs/migration.md index 7927c606..e49cb3b8 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -2499,7 +2499,9 @@ metadata's `issuer` exactly matches the authorization server URL advertised in t resource metadata, as required by [RFC 8414](https://datatracker.ietf.org/doc/html/rfc8414) section 3.3 ([SEP-2468](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2468)). The comparison is a simple string comparison ([RFC 3986](https://datatracker.ietf.org/doc/html/rfc3986) -section 6.2.1), so even a trailing-slash disagreement counts as a mismatch. v1 accepted the +section 6.2.1), so even a trailing-slash disagreement counts as a mismatch. (For an older server +that publishes no protected resource metadata the expected value is the MCP server's own origin, +and there a root issuer with a trailing slash is accepted too.) v1 accepted the metadata without checking, so a server pairing whose two values disagree authenticated fine under v1 and now fails the entire flow. For example, when the MCP server's protected resource metadata advertises @@ -2517,7 +2519,7 @@ OAuthFlowError: Authorization server metadata issuer mismatch: https://as.exampl There is no client-side override. Fix the deployment instead: make the authorization server's `issuer` string-equal the URL in the protected resource metadata's `authorization_servers` -list. See [OAuth metadata URLs no longer gain a trailing slash](#oauth-metadata-urls-no-longer-gain-a-trailing-slash) +list (or the MCP server's origin, without protected resource metadata). See [OAuth metadata URLs no longer gain a trailing slash](#oauth-metadata-urls-no-longer-gain-a-trailing-slash) for how v2 preserves the exact string form of these URLs. ### OAuth client requests `offline_access` and adds `prompt=consent` when the authorization server supports it ([SEP-2207](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2207)) diff --git a/docs_src/oauth_clients/tutorial002.py b/docs_src/oauth_clients/tutorial002.py index dd4105f9..d6323c82 100644 --- a/docs_src/oauth_clients/tutorial002.py +++ b/docs_src/oauth_clients/tutorial002.py @@ -30,6 +30,7 @@ oauth = ClientCredentialsOAuthProvider( client_id="reporting-agent", client_secret="...", scope="user", + issuer="http://localhost:9000", ) diff --git a/src/mcp/client/auth/extensions/client_credentials.py b/src/mcp/client/auth/extensions/client_credentials.py index 29197bb5..5cdefad1 100644 --- a/src/mcp/client/auth/extensions/client_credentials.py +++ b/src/mcp/client/auth/extensions/client_credentials.py @@ -9,6 +9,7 @@ Provides OAuth providers for machine-to-machine authentication flows: import time from collections.abc import Awaitable, Callable from typing import Any, Literal +from urllib.parse import urlparse from uuid import uuid4 import httpx2 @@ -16,14 +17,50 @@ import jwt from pydantic import BaseModel, Field from mcp.client.auth import OAuthClientProvider, OAuthFlowError, TokenStorage +from mcp.client.auth.oauth2 import OAuthContext +from mcp.client.auth.utils import issuers_match from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata +def _checked_issuer(issuer: str | None) -> str | None: + if issuer is not None and urlparse(issuer).scheme not in ("http", "https"): + raise ValueError(f"issuer must be the authorization server's http(s) issuer URL, got {issuer!r}") + return issuer + + +def _preferred_authorization_server(advertised: list[str], issuer: str | None) -> str: + """The advertised server matching the configured issuer if there is one, else the first.""" + return next( + (server for server in advertised if issuer is not None and issuers_match(server, issuer)), advertised[0] + ) + + +def _require_metadata_for_configured_issuer(context: OAuthContext, issuer: str | None) -> None: + """With an issuer configured, a token request is only built from metadata discovered for that issuer. + + Anything else held is dropped along with the tokens, so the next request starts discovery afresh + rather than refreshing against it. + """ + if issuer is None: + return + metadata = context.oauth_metadata + if metadata is not None and issuers_match(str(metadata.issuer), issuer): + return + context.oauth_metadata = None + context.clear_tokens() + if metadata is None: + raise OAuthFlowError(f"No authorization server metadata discovered for configured issuer {issuer}") + raise OAuthFlowError(f"Authorization server metadata issuer mismatch: {metadata.issuer} != {issuer}") + + class ClientCredentialsOAuthProvider(OAuthClientProvider): """OAuth provider for client_credentials grant with client_id + client_secret. This provider sets client_info directly, bypassing dynamic client registration. Use this when you already have client credentials (client_id and client_secret). + Pass `issuer` to name the authorization server those credentials belong to: token + requests are then only built from authorization server metadata for that issuer, and + the flow stops if the MCP server leads anywhere else. Example: ```python @@ -32,6 +69,7 @@ class ClientCredentialsOAuthProvider(OAuthClientProvider): storage=my_token_storage, client_id="my-client-id", client_secret="my-client-secret", + issuer="https://auth.example.com", ) ``` """ @@ -44,6 +82,7 @@ class ClientCredentialsOAuthProvider(OAuthClientProvider): client_secret: str, token_endpoint_auth_method: Literal["client_secret_basic", "client_secret_post"] = "client_secret_basic", scope: str | None = None, + issuer: str | None = None, ) -> None: """Initialize client_credentials OAuth provider. @@ -55,6 +94,11 @@ class ClientCredentialsOAuthProvider(OAuthClientProvider): token_endpoint_auth_method: Authentication method for token endpoint. Either "client_secret_basic" (default) or "client_secret_post". scope: Optional space-separated list of scopes to request. + issuer: The issuer identifier of the authorization server that issued + `client_id` and `client_secret`. When set, token requests are only built from + discovered authorization server metadata whose `issuer` is exactly this string; + otherwise the flow stops with `OAuthFlowError`. When omitted, whichever + authorization server discovery yields is used. """ # Build minimal client_metadata for the base class client_metadata = OAuthClientMetadata( @@ -64,6 +108,7 @@ class ClientCredentialsOAuthProvider(OAuthClientProvider): scope=scope, ) super().__init__(server_url, client_metadata, storage, None, None) + self._issuer = _checked_issuer(issuer) # Store client_info to be set during _initialize - no dynamic registration needed self._fixed_client_info = OAuthClientInformationFull( redirect_uris=None, @@ -80,12 +125,17 @@ class ClientCredentialsOAuthProvider(OAuthClientProvider): self.context.client_info = self._fixed_client_info self._initialized = True + def _select_authorization_server(self, advertised: list[str]) -> str: + return _preferred_authorization_server(advertised, self._issuer) + async def _perform_authorization(self) -> httpx2.Request: """Perform client_credentials authorization.""" return await self._exchange_token_client_credentials() async def _exchange_token_client_credentials(self) -> httpx2.Request: """Build token exchange request for client_credentials grant.""" + _require_metadata_for_configured_issuer(self.context, self._issuer) + token_data: dict[str, Any] = { "grant_type": "client_credentials", } @@ -196,7 +246,10 @@ class PrivateKeyJWTOAuthProvider(OAuthClientProvider): The JWT assertion's audience MUST be the authorization server's issuer identifier (per RFC 7523bis security updates). The `assertion_provider` callback receives - this audience value and must return a JWT with that audience. + this audience value and must return a JWT with that audience. Pass `issuer` to name + the authorization server this client is registered with: an assertion is then only + minted once metadata for that issuer has been discovered, and token requests are only + built from that metadata. **Option 1: Pre-built JWT via Workload Identity Federation** @@ -256,6 +309,7 @@ class PrivateKeyJWTOAuthProvider(OAuthClientProvider): client_id: str, assertion_provider: Callable[[str], Awaitable[str]], scope: str | None = None, + issuer: str | None = None, ) -> None: """Initialize private_key_jwt OAuth provider. @@ -269,6 +323,11 @@ class PrivateKeyJWTOAuthProvider(OAuthClientProvider): `static_assertion_provider()` for pre-built JWTs, or provide your own callback for workload identity federation. scope: Optional space-separated list of scopes to request. + issuer: The issuer identifier of the authorization server `client_id` is + registered with. When set, an assertion is only minted, and token requests + are only built, once authorization server metadata whose `issuer` is exactly this + string has been discovered; otherwise the flow stops with `OAuthFlowError`. + When omitted, whichever authorization server discovery yields is used. """ # Build minimal client_metadata for the base class client_metadata = OAuthClientMetadata( @@ -279,6 +338,7 @@ class PrivateKeyJWTOAuthProvider(OAuthClientProvider): ) super().__init__(server_url, client_metadata, storage, None, None) self._assertion_provider = assertion_provider + self._issuer = _checked_issuer(issuer) # Store client_info to be set during _initialize - no dynamic registration needed self._fixed_client_info = OAuthClientInformationFull( redirect_uris=None, @@ -294,6 +354,9 @@ class PrivateKeyJWTOAuthProvider(OAuthClientProvider): self.context.client_info = self._fixed_client_info self._initialized = True + def _select_authorization_server(self, advertised: list[str]) -> str: + return _preferred_authorization_server(advertised, self._issuer) + async def _perform_authorization(self) -> httpx2.Request: """Perform client_credentials authorization with private_key_jwt.""" return await self._exchange_token_client_credentials() @@ -314,6 +377,8 @@ class PrivateKeyJWTOAuthProvider(OAuthClientProvider): async def _exchange_token_client_credentials(self) -> httpx2.Request: """Build token exchange request for client_credentials grant with private_key_jwt.""" + _require_metadata_for_configured_issuer(self.context, self._issuer) + token_data: dict[str, Any] = { "grant_type": "client_credentials", } diff --git a/src/mcp/client/auth/oauth2.py b/src/mcp/client/auth/oauth2.py index 7dc62b52..78d6f62e 100644 --- a/src/mcp/client/auth/oauth2.py +++ b/src/mcp/client/auth/oauth2.py @@ -17,7 +17,7 @@ from urllib.parse import quote, urlencode, urljoin, urlparse import anyio import httpx2 from mcp_types.version import is_version_at_least -from pydantic import BaseModel, Field, ValidationError +from pydantic import AnyHttpUrl, BaseModel, ConfigDict, Field, TypeAdapter, ValidationError from mcp.client.auth.exceptions import OAuthFlowError, OAuthRegistrationError, OAuthTokenError from mcp.client.auth.utils import ( @@ -36,6 +36,7 @@ from mcp.client.auth.utils import ( handle_registration_response, handle_token_response_scopes, is_valid_client_metadata_url, + issuers_match, should_use_client_metadata_url, union_scopes, validate_authorization_response_iss, @@ -276,6 +277,16 @@ class OAuthContext: return data, headers +_ORIGIN_URL = TypeAdapter(AnyHttpUrl, config=ConfigDict(url_preserve_empty_path=True)) + + +def _origin_issuer(server_url: str) -> str: + """The resource server's origin as an issuer identifier: `scheme://authority`, rendered the way + `OAuthMetadata.issuer` renders URLs (host case, default ports) so the two compare as strings.""" + parsed = urlparse(server_url) + return str(_ORIGIN_URL.validate_python(f"{parsed.scheme}://{parsed.netloc}")) + + class OAuthClientProvider(httpx2.Auth): """OAuth2 authentication for httpx2. @@ -577,6 +588,16 @@ class OAuthClientProvider(httpx2.Auth): if not check_resource_allowed(requested_resource=default_resource, configured_resource=prm_resource): raise OAuthFlowError(f"Protected resource {prm_resource} does not match expected {default_resource}") + def _select_authorization_server(self, advertised: list[str]) -> str: + """Which of the servers listed in protected resource metadata to use: the first (the list is never empty).""" + return advertised[0] + + def _expected_issuer(self) -> str: + """The issuer that authorization server metadata and client credentials must belong to: the + PRM-advertised server, or on the legacy no-PRM path the resource server's origin, which is what + the 2025-03-26 well-known URL is built from (RFC 8414 §3.3).""" + return self.context.auth_server_url or _origin_issuer(self.context.server_url) + async def async_auth_flow(self, request: httpx2.Request) -> AsyncGenerator[httpx2.Request, httpx2.Response]: """httpx2 auth flow integration.""" async with self.context.lock: @@ -600,107 +621,122 @@ class OAuthClientProvider(httpx2.Auth): response = yield request - if response.status_code == 401: + step_up = ( + response.status_code == 403 and extract_field_from_www_auth(response, "error") == "insufficient_scope" + ) + + if response.status_code == 401 or step_up: # Perform full OAuth flow try: - # OAuth flow must be inline due to generator constraints - www_auth_resource_metadata_url = extract_resource_metadata_from_www_auth(response) + # Read before discovery, which may clear the tokens: on a restart the stored + # token's scope is the only record of what was granted (see Step 3). + granted_scope = self.context.current_tokens.scope if self.context.current_tokens else None - # Step 1: Discover protected resource metadata (SEP-985 with fallback support) - prm_discovery_urls = build_protected_resource_metadata_discovery_urls( - www_auth_resource_metadata_url, self.context.server_url - ) + # OAuth flow must be inline due to generator constraints. + # Steps 1-2 run on every 401. A scope step-up reuses the metadata discovered earlier + # in this process, and discovers it first when none is held yet (for example when + # tokens were loaded from storage), so re-authorization targets the right server. + if response.status_code == 401 or self.context.oauth_metadata is None: + www_auth_resource_metadata_url = extract_resource_metadata_from_www_auth(response) - for url in prm_discovery_urls: # pragma: no branch - discovery_request = create_oauth_metadata_request(url) - - discovery_response = yield discovery_request # sending request - - prm = await handle_protected_resource_response(discovery_response) - if prm: - # Validate PRM resource matches server URL (RFC 8707) - await self._validate_resource_match(prm) - self.context.protected_resource_metadata = prm - - # todo: try all authorization_servers to find the OASM - assert ( - len(prm.authorization_servers) > 0 - ) # this is always true as authorization_servers has a min length of 1 - - self.context.auth_server_url = str(prm.authorization_servers[0]) - break - else: - logger.debug(f"Protected resource metadata discovery failed: {url}") - - # SEP-2352: stored credentials are bound to the issuer that registered them. - # If the authorization server changed, drop them (and the old tokens) so the - # flow re-registers instead of presenting another server's credentials. - if ( - self.context.client_info is not None - and self.context.auth_server_url is not None - and not credentials_match_issuer( - self.context.client_info, self.context.auth_server_url, self.context.client_metadata_url + # Step 1: Discover protected resource metadata (SEP-985 with fallback support) + prm_discovery_urls = build_protected_resource_metadata_discovery_urls( + www_auth_resource_metadata_url, self.context.server_url ) - ): - logger.debug("Authorization server changed; discarding bound credentials and re-registering") - self.context.client_info = None - self.context.clear_tokens() - # Any cached AS metadata is for the old server; drop it so a failed - # rediscovery cannot leak the old registration/token endpoints into Step 4. - self.context.oauth_metadata = None - asm_discovery_urls = build_oauth_authorization_server_metadata_discovery_urls( - self.context.auth_server_url, self.context.server_url - ) + prm_request_failed: int | None = None + for url in prm_discovery_urls: + discovery_request = create_oauth_metadata_request(url) - # Step 2: Discover OAuth Authorization Server Metadata (OASM) (with fallback for legacy servers) - for url in asm_discovery_urls: # pragma: no branch - oauth_metadata_request = create_oauth_metadata_request(url) - oauth_metadata_response = yield oauth_metadata_request + discovery_response = yield discovery_request # sending request - ok, asm = await handle_auth_metadata_response(oauth_metadata_response) - if not ok: - break - if ok and asm: - # SEP-2468: metadata issuer must match the discovery issuer - if self.context.auth_server_url is not None: - validate_metadata_issuer(asm, self.context.auth_server_url) - self.context.oauth_metadata = asm - break + if discovery_response.status_code >= 500 or discovery_response.status_code == 429: + prm_request_failed = discovery_response.status_code + prm = await handle_protected_resource_response(discovery_response) + if prm: + # Validate PRM resource matches server URL (RFC 8707) + await self._validate_resource_match(prm) + self.context.protected_resource_metadata = prm + + self.context.auth_server_url = self._select_authorization_server( + [str(url) for url in prm.authorization_servers] + ) + break + else: + logger.debug(f"Protected resource metadata discovery failed: {url}") else: - logger.debug(f"OAuth metadata discovery failed: {url}") + if prm_request_failed is not None: + # A server error says nothing about whether the resource publishes + # metadata, so it must not send the flow down the legacy path. + raise OAuthFlowError( + f"Protected resource metadata request failed: HTTP {prm_request_failed}" + ) - # SEP-2352: on the legacy no-PRM path the issuer is only known after ASM - # discovery, so re-evaluate the binding here using the discovered metadata - # issuer (mirroring the bound_issuer fallback in Step 4). - if ( - self.context.client_info is not None - and self.context.auth_server_url is None - and self.context.oauth_metadata is not None - and not credentials_match_issuer( - self.context.client_info, - str(self.context.oauth_metadata.issuer), - self.context.client_metadata_url, + expected_issuer = self._expected_issuer() + + # SEP-2352: stored credentials are bound to the issuer that registered them. + # Decided before any metadata is fetched: if the expected issuer is a different + # server, drop them (and the old tokens) so the flow re-registers instead of + # presenting another server's credentials. + if self.context.client_info is not None and not credentials_match_issuer( + self.context.client_info, expected_issuer, self.context.client_metadata_url + ): + logger.debug( + "Authorization server changed; discarding bound credentials and re-registering" + ) + self.context.client_info = None + self.context.clear_tokens() + # Any cached AS metadata is for the old server; drop it so a failed + # rediscovery cannot leak the old registration/token endpoints into Step 4. + self.context.oauth_metadata = None + + asm_discovery_urls = build_oauth_authorization_server_metadata_discovery_urls( + self.context.auth_server_url, self.context.server_url ) - ): - logger.debug("Authorization server changed; discarding bound credentials and re-registering") - self.context.client_info = None - self.context.clear_tokens() + + # Step 2: Discover OAuth Authorization Server Metadata (OASM) (with fallback for legacy servers) + for url in asm_discovery_urls: # pragma: no branch + oauth_metadata_request = create_oauth_metadata_request(url) + oauth_metadata_response = yield oauth_metadata_request + + ok, asm = await handle_auth_metadata_response(oauth_metadata_response) + if not ok: + break + if ok and asm: + # SEP-2468 / RFC 8414 §3.3: the metadata must name the expected issuer. + # On the legacy path a root issuer rendered with its trailing slash + # names the same origin. + if self.context.auth_server_url is None and issuers_match( + str(asm.issuer), expected_issuer + ): + expected_issuer = str(asm.issuer) + validate_metadata_issuer(asm, expected_issuer) + self.context.oauth_metadata = asm + break + else: + logger.debug(f"OAuth metadata discovery failed: {url}") # Step 3: Apply scope selection strategy - self.context.client_metadata.scope = get_client_metadata_scopes( + challenged_scope = get_client_metadata_scopes( extract_scope_from_www_auth(response), self.context.protected_resource_metadata, self.context.oauth_metadata, self.context.client_metadata.grant_types, ) + if step_up: + # SEP-2350: union previously requested scopes with the newly challenged ones so + # escalating one operation keeps the others' grants, folding in the granted + # scope read above since client_metadata.scope is not reloaded on a restart. + prior_scope = union_scopes(self.context.client_metadata.scope, granted_scope) + self.context.client_metadata.scope = union_scopes(prior_scope, challenged_scope) + else: + self.context.client_metadata.scope = challenged_scope # Step 4: Register client or use URL-based client ID (CIMD) if not self.context.client_info: - # SEP-2352: the issuer to bind these credentials to, when known. - discovered_issuer: str | None = None - if self.context.oauth_metadata is not None: - discovered_issuer = self.context.auth_server_url or str(self.context.oauth_metadata.issuer) + # SEP-2352: the issuer to bind these credentials to, once metadata for it + # was actually found. + discovered_issuer = self._expected_issuer() if self.context.oauth_metadata is not None else None if should_use_client_metadata_url( self.context.oauth_metadata, self.context.client_metadata_url @@ -752,34 +788,3 @@ class OAuthClientProvider(httpx2.Auth): # Retry with new tokens self._add_auth_header(request) yield request - elif response.status_code == 403: - # Step 1: Extract error field from WWW-Authenticate header - error = extract_field_from_www_auth(response, "error") - - # Step 2: Check if we need to step-up authorization - if error == "insufficient_scope": # pragma: no branch - try: - # Step 2a: Union previously requested scopes with the newly challenged - # scopes (SEP-2350) so escalating one operation keeps the others' grants. - # Fold in the stored token's scope too: on a restart the token is reloaded - # but client_metadata.scope is not, so it would otherwise be the only basis. - challenged_scope = get_client_metadata_scopes( - extract_scope_from_www_auth(response), - self.context.protected_resource_metadata, - self.context.oauth_metadata, - self.context.client_metadata.grant_types, - ) - granted_scope = self.context.current_tokens.scope if self.context.current_tokens else None - prior_scope = union_scopes(self.context.client_metadata.scope, granted_scope) - self.context.client_metadata.scope = union_scopes(prior_scope, challenged_scope) - - # Step 2b: Perform (re-)authorization and token exchange - token_response = yield await self._perform_authorization() - await self._handle_token_response(token_response) - except Exception: # pragma: no cover - logger.exception("OAuth flow error") - raise - - # Retry with new tokens - self._add_auth_header(request) - yield request diff --git a/src/mcp/client/auth/utils.py b/src/mcp/client/auth/utils.py index 31e2e5ca..30d420a5 100644 --- a/src/mcp/client/auth/utils.py +++ b/src/mcp/client/auth/utils.py @@ -54,7 +54,7 @@ def extract_resource_metadata_from_www_auth(response: Response) -> str | None: Returns: Resource metadata URL if found in WWW-Authenticate header, None otherwise """ - if not response or response.status_code != 401: + if not response or response.status_code not in (401, 403): return None # pragma: no cover return extract_field_from_www_auth(response, "resource_metadata") @@ -343,7 +343,8 @@ def credentials_match_issuer( document is resolved by whichever server is in use — so it always matches; CIMD is identified by the client ID being the configured `client_metadata_url`, not by URL shape (a registration server may also issue URL-shaped IDs that are bound to it). Credentials with a recorded issuer - match only when it equals `issuer` (simple string comparison). Credentials with no recorded + match only when it equals `issuer` (simple string comparison; a root issuer with and without + its trailing slash count as equal). Credentials with no recorded issuer (pre-registered, or stored before issuer binding existed) carry no binding to enforce and are left as-is. """ @@ -351,7 +352,18 @@ def credentials_match_issuer( return True if client_info.issuer is None: return True - return client_info.issuer == issuer + return issuers_match(client_info.issuer, issuer) + + +def issuers_match(a: str, b: str) -> bool: + """Simple string comparison of two issuer identifiers (RFC 8414 section 3.3), except that a root + issuer with and without its trailing slash (`scheme://authority` and `scheme://authority/`) name + the same server.""" + if a == b: + return True + shorter, longer = sorted((a, b), key=len) + parsed = urlparse(shorter) + return longer == f"{shorter}/" and shorter == f"{parsed.scheme}://{parsed.netloc}" def should_use_client_metadata_url( diff --git a/tests/client/auth/extensions/test_client_credentials.py b/tests/client/auth/extensions/test_client_credentials.py index 16336f80..5933604e 100644 --- a/tests/client/auth/extensions/test_client_credentials.py +++ b/tests/client/auth/extensions/test_client_credentials.py @@ -1,9 +1,13 @@ import urllib.parse +from collections.abc import AsyncGenerator +import httpx2 import jwt import pytest +from inline_snapshot import snapshot from pydantic import AnyHttpUrl +from mcp.client.auth import OAuthClientProvider, OAuthFlowError from mcp.client.auth.extensions.client_credentials import ( ClientCredentialsOAuthProvider, PrivateKeyJWTOAuthProvider, @@ -27,7 +31,7 @@ class MockTokenStorage: async def get_tokens(self) -> OAuthToken | None: return self._tokens - async def set_tokens(self, tokens: OAuthToken) -> None: # pragma: no cover + async def set_tokens(self, tokens: OAuthToken) -> None: self._tokens = tokens async def get_client_info(self) -> OAuthClientInformationFull | None: # pragma: no cover @@ -325,3 +329,206 @@ class TestStaticAssertionProvider: assert result1 == token assert result2 == token + + +_SERVER_URL = "https://api.example.com/v1/mcp" +_CONFIGURED_ISSUER = "https://auth.example.com" + + +def _metadata_for(issuer: str) -> dict[str, str]: + return {"issuer": issuer, "authorization_endpoint": f"{issuer}/authorize", "token_endpoint": f"{issuer}/token"} + + +def _provider_with_issuer(kind: str, storage: MockTokenStorage, audiences: list[str]) -> OAuthClientProvider: + """A ClientCredentials ("secret") or PrivateKeyJWT ("jwt") provider configured for _CONFIGURED_ISSUER; + `audiences` records every audience an assertion is minted for.""" + if kind == "secret": + return ClientCredentialsOAuthProvider( + server_url=_SERVER_URL, storage=storage, client_id="cid", client_secret="csecret", issuer=_CONFIGURED_ISSUER + ) + + async def assertion_provider(audience: str) -> str: + audiences.append(audience) + return "signed-assertion" + + return PrivateKeyJWTOAuthProvider( + server_url=_SERVER_URL, + storage=storage, + client_id="cid", + assertion_provider=assertion_provider, + issuer=_CONFIGURED_ISSUER, + ) + + +async def _answer_discovery( + flow: AsyncGenerator[httpx2.Request, httpx2.Response], + *, + authorization_server: str | list[str] | None, + metadata: dict[str, str] | None, +) -> httpx2.Request: + """Answer the provider's first request with a 401 and its discovery requests as described; + return the request it builds once discovery is over. + + `authorization_server` is what protected-resource metadata advertises (None: no PRM is + served); `metadata` is the authorization server metadata document (None: every well-known + 404s). + """ + request = await flow.__anext__() + request = await flow.asend(httpx2.Response(401, request=request)) + while "/.well-known/oauth-protected-resource" in str(request.url): + if authorization_server is None: + response = httpx2.Response(404, request=request) + else: + advertised = authorization_server if isinstance(authorization_server, list) else [authorization_server] + prm = {"resource": _SERVER_URL, "authorization_servers": advertised} + response = httpx2.Response(200, json=prm, request=request) + request = await flow.asend(response) + while "/.well-known/" in str(request.url): + if metadata is None: + response = httpx2.Response(404, request=request) + else: + response = httpx2.Response(200, json=metadata, request=request) + request = await flow.asend(response) + return request + + +@pytest.mark.anyio +@pytest.mark.parametrize( + "served_issuer", [_CONFIGURED_ISSUER, f"{_CONFIGURED_ISSUER}/"], ids=["as-configured", "root-slash"] +) +@pytest.mark.parametrize("kind", ["secret", "jwt"]) +async def test_provider_with_configured_issuer_exchanges_at_that_issuer( + mock_storage: MockTokenStorage, kind: str, served_issuer: str +): + """SDK-defined: with `issuer=` set and metadata discovered for that issuer (a root issuer served with + its trailing slash is the same server), the token request goes to its token endpoint (positive + control for the refusals below).""" + audiences: list[str] = [] + provider = _provider_with_issuer(kind, mock_storage, audiences) + flow = provider.async_auth_flow(httpx2.Request("POST", _SERVER_URL)) + metadata = {**_metadata_for(_CONFIGURED_ISSUER), "issuer": served_issuer} + + token_request = await _answer_discovery(flow, authorization_server=served_issuer, metadata=metadata) + + assert (token_request.method, str(token_request.url)) == ("POST", "https://auth.example.com/token") + assert audiences == ([] if kind == "secret" else [served_issuer]) + await flow.aclose() + + +@pytest.mark.anyio +@pytest.mark.parametrize("kind", ["secret", "jwt"]) +async def test_provider_picks_its_configured_issuer_among_several_advertised_servers( + mock_storage: MockTokenStorage, kind: str +): + """SDK-defined: when the resource lists several authorization servers, the one matching `issuer=` is + discovered and used even if it is not listed first.""" + provider = _provider_with_issuer(kind, mock_storage, []) + flow = provider.async_auth_flow(httpx2.Request("POST", _SERVER_URL)) + + token_request = await _answer_discovery( + flow, + authorization_server=["https://other-as.example.com", _CONFIGURED_ISSUER], + metadata=_metadata_for(_CONFIGURED_ISSUER), + ) + + assert provider.context.auth_server_url == _CONFIGURED_ISSUER + assert str(token_request.url) == "https://auth.example.com/token" + await flow.aclose() + + +def test_an_issuer_that_is_not_an_http_url_is_rejected_at_construction(mock_storage: MockTokenStorage) -> None: + """SDK-defined: `issuer=` is the authorization server's issuer URL; anything else is a configuration + error on both machine-to-machine providers.""" + with pytest.raises(ValueError) as cc_error: + ClientCredentialsOAuthProvider( + server_url=_SERVER_URL, storage=mock_storage, client_id="cid", client_secret="s", issuer="auth.example.com" + ) + with pytest.raises(ValueError) as jwt_error: + PrivateKeyJWTOAuthProvider( + server_url=_SERVER_URL, + storage=mock_storage, + client_id="cid", + assertion_provider=static_assertion_provider("jwt"), + issuer="auth.example.com", + ) + assert ( + str(cc_error.value) + == str(jwt_error.value) + == snapshot("issuer must be the authorization server's http(s) issuer URL, got 'auth.example.com'") + ) + + +@pytest.mark.anyio +@pytest.mark.parametrize("kind", ["secret", "jwt"]) +async def test_provider_refuses_metadata_for_a_different_issuer(mock_storage: MockTokenStorage, kind: str): + """SDK-defined: when discovery ends at an authorization server other than the configured `issuer`, + no token request is built and no assertion is minted.""" + audiences: list[str] = [] + provider = _provider_with_issuer(kind, mock_storage, audiences) + flow = provider.async_auth_flow(httpx2.Request("POST", _SERVER_URL)) + + with pytest.raises(OAuthFlowError) as exc_info: + await _answer_discovery( + flow, + authorization_server="https://other-as.example.com", + metadata=_metadata_for("https://other-as.example.com"), + ) + + assert str(exc_info.value) == snapshot( + "Authorization server metadata issuer mismatch: https://other-as.example.com != https://auth.example.com" + ) + assert audiences == [] + + +@pytest.mark.anyio +@pytest.mark.parametrize("kind", ["secret", "jwt"]) +async def test_provider_refuses_to_exchange_without_metadata_when_issuer_configured( + mock_storage: MockTokenStorage, kind: str +): + """SDK-defined: with `issuer=` set, the 2025-03-26 default `/token` on the resource origin is not + used when no authorization server metadata could be discovered.""" + audiences: list[str] = [] + provider = _provider_with_issuer(kind, mock_storage, audiences) + flow = provider.async_auth_flow(httpx2.Request("POST", _SERVER_URL)) + + with pytest.raises(OAuthFlowError) as exc_info: + await _answer_discovery(flow, authorization_server=None, metadata=None) + + assert str(exc_info.value) == snapshot( + "No authorization server metadata discovered for configured issuer https://auth.example.com" + ) + assert audiences == [] + + +@pytest.mark.anyio +@pytest.mark.parametrize("kind", ["secret", "jwt"]) +async def test_a_refused_authorization_server_is_forgotten_so_the_next_request_rediscovers( + mock_storage: MockTokenStorage, kind: str +): + """SDK-defined: when the exchange is refused because discovery ended somewhere other than the + configured issuer, the refused metadata and any token held are dropped; the next request goes out + unauthenticated and discovery starts again, rather than a refresh being built from what was refused.""" + provider = _provider_with_issuer(kind, mock_storage, []) + flow = provider.async_auth_flow(httpx2.Request("POST", _SERVER_URL)) + token_request = await _answer_discovery( + flow, authorization_server=_CONFIGURED_ISSUER, metadata=_metadata_for(_CONFIGURED_ISSUER) + ) + token = {"access_token": "first", "token_type": "Bearer", "expires_in": 3600, "refresh_token": "rt"} + retried = await flow.asend(httpx2.Response(200, json=token, request=token_request)) + with pytest.raises(StopAsyncIteration): + await flow.asend(httpx2.Response(200, request=retried)) + + flow = provider.async_auth_flow(httpx2.Request("POST", _SERVER_URL)) + with pytest.raises(OAuthFlowError): + await _answer_discovery( + flow, + authorization_server="https://other-as.example.com", + metadata=_metadata_for("https://other-as.example.com"), + ) + assert provider.context.oauth_metadata is None + assert provider.context.current_tokens is None + + flow = provider.async_auth_flow(httpx2.Request("POST", _SERVER_URL)) + request = await flow.__anext__() + assert (str(request.url), request.headers.get("Authorization")) == (_SERVER_URL, None) + await flow.aclose() diff --git a/tests/client/test_auth.py b/tests/client/test_auth.py index be96cc8e..eb168e6e 100644 --- a/tests/client/test_auth.py +++ b/tests/client/test_auth.py @@ -1502,8 +1502,11 @@ class TestAuthFlow: request=request, ) - # Trigger step-up - should get token exchange request - token_exchange_request = await auth_flow.asend(response_403) + # Trigger step-up - discovery runs first (nothing published here), then the token exchange + prm_request = await auth_flow.asend(response_403) + prm_request = await auth_flow.asend(httpx2.Response(404, request=prm_request)) + asm_request = await auth_flow.asend(httpx2.Response(404, request=prm_request)) + token_exchange_request = await auth_flow.asend(httpx2.Response(404, request=asm_request)) # Verify scope was updated to the union of prior and challenged scopes (SEP-2350) assert oauth_provider.context.client_metadata.scope == "read write admin:write admin:delete" @@ -1576,7 +1579,10 @@ async def test_403_step_up_preserves_scope_from_stored_token( headers={"WWW-Authenticate": 'Bearer error="insufficient_scope", scope="write"'}, request=request, ) - token_exchange_request = await auth_flow.asend(response_403) + prm_request = await auth_flow.asend(response_403) + prm_request = await auth_flow.asend(httpx2.Response(404, request=prm_request)) + asm_request = await auth_flow.asend(httpx2.Response(404, request=prm_request)) + token_exchange_request = await auth_flow.asend(httpx2.Response(404, request=asm_request)) assert reauthorize_scope == "read write" @@ -1593,6 +1599,101 @@ async def test_403_step_up_preserves_scope_from_stored_token( pass +@pytest.mark.anyio +async def test_scope_step_up_discovers_the_authorization_server_before_reauthorizing( + oauth_provider: OAuthClientProvider, mock_storage: MockTokenStorage, valid_tokens: OAuthToken +): + """SDK-defined: a 403 scope challenge runs discovery first when no metadata is held, so + re-authorization targets the advertised server. + + Steps: + 1. A restarted client holds a token and a registration but no authorization server metadata. + 2. The first response is 403 insufficient_scope -> the next requests are PRM (at the challenge's + `resource_metadata` URL) then ASM discovery. + 3. The authorization redirect and the token request use the discovered server's endpoints, and + the requested scope is the SEP-2350 union. + """ + await mock_storage.set_tokens(valid_tokens) + await mock_storage.set_client_info( + OAuthClientInformationFull( + client_id="registered-client", redirect_uris=[AnyUrl("http://localhost:3030/callback")] + ) + ) + redirects: list[str] = [] + + async def record_redirect(url: str) -> None: + redirects.append(url) + + async def echo_callback() -> AuthorizationCodeResult: + state = parse_qs(urlparse(redirects[-1]).query)["state"][0] + return AuthorizationCodeResult(code="auth_code", state=state) + + oauth_provider.context.redirect_handler = record_redirect + oauth_provider.context.callback_handler = echo_callback + + auth_flow = oauth_provider.async_auth_flow(httpx2.Request("GET", "https://api.example.com/v1/mcp")) + request = await auth_flow.__anext__() + response_403 = httpx2.Response( + 403, + headers={ + "WWW-Authenticate": 'Bearer error="insufficient_scope", scope="admin",' + ' resource_metadata="https://api.example.com/v1/mcp/resource-metadata"' + }, + request=request, + ) + + prm_request = await auth_flow.asend(response_403) + assert prm_request.method == "GET" + assert str(prm_request.url) == "https://api.example.com/v1/mcp/resource-metadata" + prm_response = httpx2.Response( + 200, + content=b'{"resource": "https://api.example.com/v1/mcp", "authorization_servers": ["https://auth.example.com"]}', + request=prm_request, + ) + asm_request = await auth_flow.asend(prm_response) + assert str(asm_request.url) == "https://auth.example.com/.well-known/oauth-authorization-server" + asm_response = httpx2.Response( + 200, + content=( + b'{"issuer": "https://auth.example.com",' + b' "authorization_endpoint": "https://auth.example.com/authorize",' + b' "token_endpoint": "https://auth.example.com/token"}' + ), + request=asm_request, + ) + + token_request = await auth_flow.asend(asm_response) + assert redirects[-1].startswith("https://auth.example.com/authorize?") + assert parse_qs(urlparse(redirects[-1]).query)["scope"] == ["read write admin"] + assert token_request.method == "POST" + assert str(token_request.url) == "https://auth.example.com/token" + + final_request = await auth_flow.asend( + httpx2.Response( + 200, json={"access_token": "stepped_up", "token_type": "Bearer", "expires_in": 3600}, request=token_request + ) + ) + assert final_request.headers["Authorization"] == "Bearer stepped_up" + with pytest.raises(StopAsyncIteration): + await auth_flow.asend(httpx2.Response(200, request=final_request)) + + +@pytest.mark.anyio +async def test_403_without_a_scope_challenge_is_returned_to_the_caller( + oauth_provider: OAuthClientProvider, mock_storage: MockTokenStorage, valid_tokens: OAuthToken +): + """SDK-defined: a 403 that is not an insufficient_scope challenge ends the flow; the request is + not retried.""" + await mock_storage.set_tokens(valid_tokens) + + auth_flow = oauth_provider.async_auth_flow(httpx2.Request("GET", "https://api.example.com/v1/mcp")) + request = await auth_flow.__anext__() + response_403 = httpx2.Response(403, headers={"WWW-Authenticate": 'Bearer error="access_denied"'}, request=request) + + with pytest.raises(StopAsyncIteration): + await auth_flow.asend(response_403) + + @pytest.mark.parametrize( ( "issuer_url", @@ -1818,8 +1919,8 @@ class TestLegacyServerFallback: prm_request_1 = await auth_flow.asend(response) assert str(prm_request_1.url) == "https://custom.prm.com/.well-known/oauth-protected-resource" - # Returns 500 - prm_response_1 = httpx2.Response(500, request=prm_request_1) + # Not served there + prm_response_1 = httpx2.Response(404, request=prm_request_1) # Try path-based fallback prm_request_2 = await auth_flow.asend(prm_response_1) @@ -2891,6 +2992,25 @@ def test_credentials_match_issuer_different_issuer(): assert credentials_match_issuer(info, "https://other", None) is False +@pytest.mark.parametrize( + ("recorded", "current"), + [("https://as.example.com", "https://as.example.com/"), ("https://as.example.com/", "https://as.example.com")], + ids=["current-has-root-slash", "recorded-has-root-slash"], +) +def test_credentials_match_issuer_treats_root_slash_as_the_same_issuer(recorded: str, current: str): + """SDK-defined: a root issuer recorded with and without its trailing slash names the same server.""" + info = OAuthClientInformationFull(client_id="c", redirect_uris=[AnyUrl("http://localhost/cb")], issuer=recorded) + assert credentials_match_issuer(info, current, None) is True + + +def test_credentials_match_issuer_root_slash_tolerance_does_not_extend_to_other_paths(): + """SDK-defined: a trailing slash on a non-root path is a different issuer.""" + info = OAuthClientInformationFull( + client_id="c", redirect_uris=[AnyUrl("http://localhost/cb")], issuer="https://as.example.com/tenant" + ) + assert credentials_match_issuer(info, "https://as.example.com/tenant/", None) is False + + def test_credentials_match_issuer_no_recorded_issuer_is_left_alone(): """Credentials with no bound issuer (pre-registered / legacy) carry no binding to enforce.""" info = OAuthClientInformationFull(client_id="c", redirect_uris=[AnyUrl("http://localhost/cb")]) @@ -3008,15 +3128,14 @@ async def test_handle_refresh_response_adopts_rotated_refresh_token_when_returne @pytest.mark.anyio -async def test_issuer_binding_re_evaluated_after_asm_when_prm_discovery_failed( +async def test_issuer_binding_evaluated_against_the_server_origin_when_prm_discovery_failed( oauth_provider: OAuthClientProvider, ): - """SEP-2352: on the legacy no-PRM path the binding check uses the ASM-discovered issuer. + """SEP-2352: on the legacy no-PRM path the binding check uses the resource server's origin. - PRM discovery fails (404) so ``auth_server_url`` stays ``None`` and the post-PRM check is - skipped; when ASM discovery then succeeds via the root well-known fallback, the discovered - metadata's issuer is compared against the stored credentials' bound issuer and a mismatch - triggers re-registration. + PRM discovery fails (404) so ``auth_server_url`` stays ``None``; the expected issuer is then the + origin the legacy well-known URL is built from, so stored credentials bound to another issuer are + discarded before ASM discovery runs, and re-registration follows. """ oauth_provider.context.current_tokens = None oauth_provider.context.token_expiry_time = None @@ -3037,9 +3156,11 @@ async def test_issuer_binding_re_evaluated_after_asm_when_prm_discovery_failed( prm_req = await auth_flow.asend(httpx2.Response(404, request=prm_req)) assert str(prm_req.url) == "https://api.example.com/.well-known/oauth-protected-resource" - # ASM discovery via root fallback (no auth_server_url) succeeds with a different issuer. + # ASM discovery via root fallback (no auth_server_url): the stale credentials are already + # gone when the request is issued. asm_req = await auth_flow.asend(httpx2.Response(404, request=prm_req)) assert str(asm_req.url) == "https://api.example.com/.well-known/oauth-authorization-server" + assert oauth_provider.context.client_info is None asm_response = httpx2.Response( 200, content=( @@ -3060,6 +3181,208 @@ async def test_issuer_binding_re_evaluated_after_asm_when_prm_discovery_failed( await auth_flow.aclose() +@pytest.mark.anyio +async def test_legacy_fallback_metadata_naming_a_different_issuer_is_refused(oauth_provider: OAuthClientProvider): + """RFC 8414 section 3.3 on the legacy no-PRM path: metadata served from the resource server's + own well-known must name that origin as its issuer; anything else stops the flow before any + authorization or token request is built.""" + auth_flow = oauth_provider.async_auth_flow(httpx2.Request("GET", "https://api.example.com/v1/mcp")) + request = await auth_flow.__anext__() + + # 401 without WWW-Authenticate; both PRM well-knowns 404; legacy root ASM discovery. + prm_req = await auth_flow.asend(httpx2.Response(401, request=request)) + prm_req = await auth_flow.asend(httpx2.Response(404, request=prm_req)) + asm_req = await auth_flow.asend(httpx2.Response(404, request=prm_req)) + assert str(asm_req.url) == "https://api.example.com/.well-known/oauth-authorization-server" + + asm_response = httpx2.Response( + 200, + content=( + b'{"issuer": "https://other-as.example.com", ' + b'"authorization_endpoint": "https://other-as.example.com/authorize", ' + b'"token_endpoint": "https://other-as.example.com/token"}' + ), + request=asm_req, + ) + with pytest.raises(OAuthFlowError) as exc_info: + await auth_flow.asend(asm_response) + + assert str(exc_info.value) == snapshot( + "Authorization server metadata issuer mismatch: https://other-as.example.com != https://api.example.com" + ) + + +@pytest.mark.anyio +@pytest.mark.parametrize( + "first_response", + [(401, {}), (403, {"WWW-Authenticate": 'Bearer error="insufficient_scope", scope="admin"'})], + ids=["401", "403-insufficient-scope"], +) +async def test_legacy_fallback_without_metadata_re_registers_instead_of_presenting_credentials_bound_elsewhere( + oauth_provider: OAuthClientProvider, + mock_storage: MockTokenStorage, + valid_tokens: OAuthToken, + first_response: tuple[int, dict[str, str]], +): + """SEP-2352 on the legacy no-PRM path when no metadata is served at all, whether the flow starts + from a 401 or from a 403 scope challenge with no metadata held. + + Steps: + 1. Storage holds a token and a confidential client bound to a different authorization server. + 2. Both PRM well-knowns 404 -> the expected issuer is the resource server's origin, so the stored + client is discarded before ASM discovery. + 3. The origin's ASM well-known 404s too -> the flow registers a fresh client at the + origin's default `/register` and authorizes with it; the token request to the origin's + default `/token` carries the new client and none of the discarded credentials. + """ + await mock_storage.set_tokens(valid_tokens) + await mock_storage.set_client_info( + OAuthClientInformationFull( + client_id="confidential-client", + client_secret="stored-secret", + token_endpoint_auth_method="client_secret_post", + redirect_uris=[AnyUrl("http://localhost:3030/callback")], + issuer="https://other-as.example.com", + ) + ) + redirects: list[str] = [] + + async def record_redirect(url: str) -> None: + redirects.append(url) + + async def echo_callback() -> AuthorizationCodeResult: + state = parse_qs(urlparse(redirects[-1]).query)["state"][0] + return AuthorizationCodeResult(code="auth_code", state=state) + + oauth_provider.context.redirect_handler = record_redirect + oauth_provider.context.callback_handler = echo_callback + + auth_flow = oauth_provider.async_auth_flow(httpx2.Request("GET", "https://api.example.com/v1/mcp")) + request = await auth_flow.__anext__() + + status, headers = first_response + prm_req = await auth_flow.asend(httpx2.Response(status, headers=headers, request=request)) + prm_req = await auth_flow.asend(httpx2.Response(404, request=prm_req)) + asm_req = await auth_flow.asend(httpx2.Response(404, request=prm_req)) + assert str(asm_req.url) == "https://api.example.com/.well-known/oauth-authorization-server" + + # No metadata at the origin either: register at the origin's default endpoint. + register_req = await auth_flow.asend(httpx2.Response(404, request=asm_req)) + assert register_req.method == "POST" + assert str(register_req.url) == "https://api.example.com/register" + register_response = httpx2.Response( + 201, + json={ + "client_id": "origin-client", + "redirect_uris": ["http://localhost:3030/callback"], + "token_endpoint_auth_method": "none", + }, + request=register_req, + ) + + token_req = await auth_flow.asend(register_response) + assert str(token_req.url) == "https://api.example.com/token" + assert redirects[-1].startswith("https://api.example.com/authorize?") + token_form = parse_qs(token_req.content.decode()) + assert token_form["client_id"] == ["origin-client"] + assert "client_secret" not in token_form + await auth_flow.aclose() + + +@pytest.mark.anyio +@pytest.mark.parametrize("status", [500, 503, 429]) +async def test_a_failing_resource_metadata_request_stops_the_flow_and_keeps_stored_credentials( + oauth_provider: OAuthClientProvider, mock_storage: MockTokenStorage, valid_tokens: OAuthToken, status: int +): + """SDK-defined: a server error (or 429) on a protected resource metadata request says nothing about + whether the server publishes that metadata. The remaining well-known locations are still tried, but + when none answers the flow stops instead of taking the legacy path, and a registration bound to the + advertised authorization server and its tokens stay as they were.""" + bound = OAuthClientInformationFull( + client_id="registered-client", + redirect_uris=[AnyUrl("http://localhost:3030/callback")], + issuer="https://auth.example.com", + ) + await mock_storage.set_client_info(bound) + await mock_storage.set_tokens(valid_tokens) + flow = oauth_provider.async_auth_flow(httpx2.Request("GET", "https://api.example.com/v1/mcp")) + request = await flow.__anext__() + prm_request = await flow.asend(httpx2.Response(401, request=request)) + root_prm_request = await flow.asend(httpx2.Response(status, request=prm_request)) + assert str(root_prm_request.url) == "https://api.example.com/.well-known/oauth-protected-resource" + + with pytest.raises(OAuthFlowError) as exc_info: + await flow.asend(httpx2.Response(404, request=root_prm_request)) + + assert str(exc_info.value) == f"Protected resource metadata request failed: HTTP {status}" + assert oauth_provider.context.client_info == bound + assert oauth_provider.context.current_tokens == valid_tokens + assert await mock_storage.get_client_info() == bound + + +@pytest.mark.anyio +async def test_a_failing_resource_metadata_location_does_not_matter_when_another_one_answers( + oauth_provider: OAuthClientProvider, +): + """SDK-defined: the well-known locations are tried in order; an error at one of them is forgotten + once a later one returns the metadata.""" + flow = oauth_provider.async_auth_flow(httpx2.Request("GET", "https://api.example.com/v1/mcp")) + request = await flow.__anext__() + prm_request = await flow.asend(httpx2.Response(401, request=request)) + root_prm_request = await flow.asend(httpx2.Response(503, request=prm_request)) + prm = b'{"resource": "https://api.example.com/v1/mcp", "authorization_servers": ["https://auth.example.com"]}' + + asm_request = await flow.asend(httpx2.Response(200, content=prm, request=root_prm_request)) + + assert str(asm_request.url) == "https://auth.example.com/.well-known/oauth-authorization-server" + await flow.aclose() + + +@pytest.mark.anyio +async def test_legacy_fallback_accepts_a_root_slash_issuer_for_a_server_url_in_any_spelling( + client_metadata: OAuthClientMetadata, mock_storage: MockTokenStorage +): + """SDK-defined: on the legacy no-PRM path the expected issuer is the resource server's origin; a + `server_url` written with an upper-case host and an explicit default port still matches metadata + naming that origin, with or without the trailing slash a root issuer is often rendered with, and + the flow proceeds to registration.""" + + async def redirect_handler(url: str) -> None: + raise NotImplementedError + + async def callback_handler() -> AuthorizationCodeResult: + raise NotImplementedError + + provider = OAuthClientProvider( + server_url="https://API.Example.com:443/v1/mcp", + client_metadata=client_metadata, + storage=mock_storage, + redirect_handler=redirect_handler, + callback_handler=callback_handler, + ) + auth_flow = provider.async_auth_flow(httpx2.Request("GET", "https://API.Example.com:443/v1/mcp")) + request = await auth_flow.__anext__() + prm_req = await auth_flow.asend(httpx2.Response(401, request=request)) + prm_req = await auth_flow.asend(httpx2.Response(404, request=prm_req)) + asm_req = await auth_flow.asend(httpx2.Response(404, request=prm_req)) + asm_response = httpx2.Response( + 200, + content=( + b'{"issuer": "https://api.example.com/", ' + b'"authorization_endpoint": "https://api.example.com/authorize", ' + b'"token_endpoint": "https://api.example.com/token", ' + b'"registration_endpoint": "https://api.example.com/register"}' + ), + request=asm_req, + ) + + register_req = await auth_flow.asend(asm_response) + + assert register_req.method == "POST" + assert str(register_req.url) == "https://api.example.com/register" + await auth_flow.aclose() + + @pytest.mark.anyio @pytest.mark.parametrize( "asm_responses", diff --git a/tests/docs_src/test_oauth_clients.py b/tests/docs_src/test_oauth_clients.py index 801fbb2c..a4ec05d9 100644 --- a/tests/docs_src/test_oauth_clients.py +++ b/tests/docs_src/test_oauth_clients.py @@ -9,6 +9,7 @@ from pydantic import AnyUrl, ValidationError from docs_src.oauth_clients import tutorial001, tutorial002 from mcp.client.auth import OAuthClientProvider, OAuthFlowError, OAuthRegistrationError, OAuthTokenError, TokenStorage from mcp.client.auth.extensions.client_credentials import ( + ClientCredentialsOAuthProvider, PrivateKeyJWTOAuthProvider, static_assertion_provider, ) @@ -80,6 +81,14 @@ async def test_client_credentials_provider_builds_its_own_metadata() -> None: assert metadata.scope == "user" +@pytest.mark.parametrize("provider_class", [ClientCredentialsOAuthProvider, PrivateKeyJWTOAuthProvider]) +async def test_issuer_is_an_optional_keyword_on_both_machine_to_machine_providers(provider_class: type) -> None: + """tutorial002 passes `issuer=`; the page says leaving it out is allowed, on either provider.""" + issuer = inspect.signature(provider_class.__init__).parameters["issuer"] + assert issuer.kind is inspect.Parameter.POSITIONAL_OR_KEYWORD + assert issuer.default is None + + async def test_the_two_remaining_keyword_arguments_have_defaults() -> None: """The page names `client_metadata_url` and `validate_resource_url` as the remainder.""" parameters = inspect.signature(OAuthClientProvider.__init__).parameters From 979208c1157661eac0151f90974a1f96528003fa Mon Sep 17 00:00:00 2001 From: Max <224885523+maxisbey@users.noreply.github.com> Date: Fri, 4 Sep 2026 15:53:21 +0100 Subject: [PATCH 06/20] Deprecate constructing the pre-provisioned OAuth clients without an issuer (#3435) --- docs/client/oauth-clients.md | 2 +- docs/deprecated.md | 7 +- .../oauth_client_credentials/README.md | 4 +- .../oauth_client_credentials/client.py | 5 +- .../auth/extensions/client_credentials.py | 24 ++++++- .../extensions/test_client_credentials.py | 69 +++++++++++++++++++ tests/docs_src/test_oauth_clients.py | 1 + tests/interaction/auth/test_lifecycle.py | 2 + 8 files changed, 103 insertions(+), 11 deletions(-) diff --git a/docs/client/oauth-clients.md b/docs/client/oauth-clients.md index 3954fd53..fe9f8111 100644 --- a/docs/client/oauth-clients.md +++ b/docs/client/oauth-clients.md @@ -112,7 +112,7 @@ A nightly job, a CI step, another service. There is no browser and nobody to cli What changed: * No `OAuthClientMetadata`, no handlers. You pass `client_id` and `client_secret`; the provider builds a minimal `client_credentials` registration around them and skips dynamic registration entirely. -* `issuer` names the authorization server that issued those credentials; use the `issuer` value its `/.well-known/oauth-authorization-server` document returns. Discovery still runs as above, but token requests are only ever built from metadata for *that* issuer; if the MCP server points anywhere else, the flow stops with an `OAuthFlowError` instead. Leave it out and the provider uses whichever authorization server discovery finds. +* `issuer` names the authorization server that issued those credentials; use the `issuer` value its `/.well-known/oauth-authorization-server` document returns. Discovery still runs as above, but token requests are only ever built from metadata for *that* issuer; if the MCP server points anywhere else, the flow stops with an `OAuthFlowError` instead. Leaving it out is deprecated and it becomes required in 3.0 (see **[Deprecated features](../deprecated.md#deprecated-sdk-helpers)**); until then the provider warns and uses whichever authorization server discovery finds. * `scope` is a space-separated string, the OAuth wire format. * Everything downstream is identical: the same `TokenStorage`, the same `httpx2.AsyncClient(auth=...)`, the same `streamable_http_client`. diff --git a/docs/deprecated.md b/docs/deprecated.md index d1d724fa..40de550f 100644 --- a/docs/deprecated.md +++ b/docs/deprecated.md @@ -1,6 +1,6 @@ # Deprecated features -The 2026-07-28 spec retires five things. The SDK still implements every one of them, and every one of them now carries a **deprecation warning**. One SDK helper is deprecated on its own account and is listed [at the end](#deprecated-sdk-helpers). +The 2026-07-28 spec retires five things. The SDK still implements every one of them, and every one of them now carries a **deprecation warning**. A few SDK-level deprecations stand on their own account and are listed [at the end](#deprecated-sdk-helpers). The table below names each deprecated feature, why it is going away, and the replacement to build on. @@ -131,11 +131,12 @@ That is the whole API. There is no per-method switch, and you don't want one: th ## Deprecated SDK helpers -These are not spec changes, only SDK internals with a better replacement. They warn with the same `MCPDeprecationWarning` and will be removed in 3.0. +These are not spec changes, only SDK usage with a better replacement. They warn with the same `MCPDeprecationWarning`, and 3.0 removes the old form. | Deprecated | What you do instead | |---|---| | `FuncMetadata.call_fn_with_arg_validation()` | `FuncMetadata.validate_arguments()` and then `FuncMetadata.call_fn()`. Only code that drives `FuncMetadata` directly (a custom `Tool` subclass, say) ever called it. | +| `ClientCredentialsOAuthProvider(...)` or `PrivateKeyJWTOAuthProvider(...)` without `issuer=` | Pass `issuer=` naming the authorization server that issued the credentials (see **[Writing OAuth clients](client/oauth-clients.md#machine-to-machine)**). Without it the MCP server decides which authorization server receives them; 3.0 makes the keyword required. | ## Recap @@ -144,7 +145,7 @@ These are not spec changes, only SDK internals with a better replacement. They w * Deprecated is advisory: no wire changes, everything keeps working against pre-2026 sessions, and you get a visible `MCPDeprecationWarning` (a `UserWarning`, so it is on by default). * Sampling and roots additionally need a back-channel that a 2026-07-28 session does not have. On a modern connection they warn and then they raise. * `warnings.filterwarnings("ignore", category=MCPDeprecationWarning)` silences the whole category; `"error::mcp.MCPDeprecationWarning"` in pytest turns it into a test failure. -* One SDK helper, `FuncMetadata.call_fn_with_arg_validation()`, is deprecated separately for removal in 3.0. +* The [SDK-level deprecations](#deprecated-sdk-helpers) follow the same rule: they warn now, and 3.0 drops the old form. * New code should not be built on any of these. Every other page in these docs teaches the current API. diff --git a/examples/stories/oauth_client_credentials/README.md b/examples/stories/oauth_client_credentials/README.md index d58b004c..82bc3c42 100644 --- a/examples/stories/oauth_client_credentials/README.md +++ b/examples/stories/oauth_client_credentials/README.md @@ -33,8 +33,8 @@ the client and server side. - `client.py` `main` — opens with `async with Client(target, mode=mode) as client:` and that's the whole program. `target` is a transport that already carries the OAuth `httpx2.Auth`; the body never touches a token. -- `client.py` `build_auth` — five lines of `ClientCredentialsOAuthProvider` - config is all the caller writes; the SDK does RFC 9728 PRM → +- `client.py` `build_auth` — a few lines of `ClientCredentialsOAuthProvider` + config (credentials plus `issuer=`) is all the caller writes; the SDK does RFC 9728 PRM → RFC 8414 AS-metadata discovery and token exchange on the first 401. - `server.py` `token_endpoint` — the *entire* AS for this grant: validate HTTP-Basic `client_id:client_secret`, mint a token, return RFC 6749 JSON. diff --git a/examples/stories/oauth_client_credentials/client.py b/examples/stories/oauth_client_credentials/client.py index 78dc7c7c..72e10868 100644 --- a/examples/stories/oauth_client_credentials/client.py +++ b/examples/stories/oauth_client_credentials/client.py @@ -8,13 +8,13 @@ from stories._harness import Target, run_client # MCP_URL pins the resource to :8000, and the server side builds its PRM/AS metadata from # the same constant — run the server on 8000 or the discovery chain points at the wrong origin. -from stories._shared.auth import MCP_URL, InMemoryTokenStorage +from stories._shared.auth import BASE_URL, MCP_URL, InMemoryTokenStorage from .server import DEMO_CLIENT_ID, DEMO_CLIENT_SECRET, DEMO_SCOPE def build_auth(_http: httpx2.AsyncClient) -> httpx2.Auth: - """The ``httpx2.Auth`` for the ``client_credentials`` grant — five lines of provider config. + """The ``httpx2.Auth`` for the ``client_credentials`` grant — a few lines of provider config. The SDK then handles 401 → RFC 9728 PRM → RFC 8414 AS-metadata discovery → token POST → Bearer attachment automatically. ``Client(url)`` has no ``auth=`` passthrough yet, so the @@ -27,6 +27,7 @@ def build_auth(_http: httpx2.AsyncClient) -> httpx2.Auth: client_id=DEMO_CLIENT_ID, client_secret=DEMO_CLIENT_SECRET, scope=DEMO_SCOPE, + issuer=BASE_URL, ) diff --git a/src/mcp/client/auth/extensions/client_credentials.py b/src/mcp/client/auth/extensions/client_credentials.py index 5cdefad1..091f9e39 100644 --- a/src/mcp/client/auth/extensions/client_credentials.py +++ b/src/mcp/client/auth/extensions/client_credentials.py @@ -7,6 +7,7 @@ Provides OAuth providers for machine-to-machine authentication flows: """ import time +import warnings from collections.abc import Awaitable, Callable from typing import Any, Literal from urllib.parse import urlparse @@ -20,10 +21,20 @@ from mcp.client.auth import OAuthClientProvider, OAuthFlowError, TokenStorage from mcp.client.auth.oauth2 import OAuthContext from mcp.client.auth.utils import issuers_match from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata +from mcp.shared.exceptions import MCPDeprecationWarning def _checked_issuer(issuer: str | None) -> str | None: - if issuer is not None and urlparse(issuer).scheme not in ("http", "https"): + if issuer is None: + warnings.warn( + "Omitting `issuer` is deprecated and it will be required in 3.0. Without it, the MCP server " + "decides which authorization server receives this client's credentials; pass " + "issuer= so they are only ever sent there.", + MCPDeprecationWarning, + stacklevel=3, + ) + return None + if urlparse(issuer).scheme not in ("http", "https"): raise ValueError(f"issuer must be the authorization server's http(s) issuer URL, got {issuer!r}") return issuer @@ -97,7 +108,8 @@ class ClientCredentialsOAuthProvider(OAuthClientProvider): issuer: The issuer identifier of the authorization server that issued `client_id` and `client_secret`. When set, token requests are only built from discovered authorization server metadata whose `issuer` is exactly this string; - otherwise the flow stops with `OAuthFlowError`. When omitted, whichever + otherwise the flow stops with `OAuthFlowError`. Omitting it is deprecated + (`MCPDeprecationWarning`) and it will be required in 3.0; until then, whichever authorization server discovery yields is used. """ # Build minimal client_metadata for the base class @@ -168,6 +180,7 @@ def static_assertion_provider(token: str) -> Callable[[str], Awaitable[str]]: storage=my_token_storage, client_id="my-client-id", assertion_provider=static_assertion_provider(my_prebuilt_jwt), + issuer="https://auth.example.com", ) ``` @@ -202,6 +215,7 @@ class SignedJWTParameters(BaseModel): storage=my_token_storage, client_id="my-client-id", assertion_provider=jwt_params.create_assertion_provider(), + issuer="https://auth.example.com", ) ``` """ @@ -267,6 +281,7 @@ class PrivateKeyJWTOAuthProvider(OAuthClientProvider): storage=my_token_storage, client_id="my-client-id", assertion_provider=get_workload_identity_token, + issuer="https://auth.example.com", ) ``` @@ -280,6 +295,7 @@ class PrivateKeyJWTOAuthProvider(OAuthClientProvider): storage=my_token_storage, client_id="my-client-id", assertion_provider=static_assertion_provider(my_prebuilt_jwt), + issuer="https://auth.example.com", ) ``` @@ -298,6 +314,7 @@ class PrivateKeyJWTOAuthProvider(OAuthClientProvider): storage=my_token_storage, client_id="my-client-id", assertion_provider=jwt_params.create_assertion_provider(), + issuer="https://auth.example.com", ) ``` """ @@ -327,7 +344,8 @@ class PrivateKeyJWTOAuthProvider(OAuthClientProvider): registered with. When set, an assertion is only minted, and token requests are only built, once authorization server metadata whose `issuer` is exactly this string has been discovered; otherwise the flow stops with `OAuthFlowError`. - When omitted, whichever authorization server discovery yields is used. + Omitting it is deprecated (`MCPDeprecationWarning`) and it will be required in + 3.0; until then, whichever authorization server discovery yields is used. """ # Build minimal client_metadata for the base class client_metadata = OAuthClientMetadata( diff --git a/tests/client/auth/extensions/test_client_credentials.py b/tests/client/auth/extensions/test_client_credentials.py index 5933604e..3b320c59 100644 --- a/tests/client/auth/extensions/test_client_credentials.py +++ b/tests/client/auth/extensions/test_client_credentials.py @@ -7,6 +7,7 @@ import pytest from inline_snapshot import snapshot from pydantic import AnyHttpUrl +from mcp import MCPDeprecationWarning from mcp.client.auth import OAuthClientProvider, OAuthFlowError from mcp.client.auth.extensions.client_credentials import ( ClientCredentialsOAuthProvider, @@ -57,6 +58,7 @@ class TestClientCredentialsOAuthProvider: storage=mock_storage, client_id="test-client-id", client_secret="test-client-secret", + issuer="https://api.example.com", ) # client_info is set during _initialize @@ -77,6 +79,7 @@ class TestClientCredentialsOAuthProvider: client_id="test-client-id", client_secret="test-client-secret", scope="read write", + issuer="https://api.example.com", ) await provider._initialize() @@ -92,6 +95,7 @@ class TestClientCredentialsOAuthProvider: client_id="test-client-id", client_secret="test-client-secret", token_endpoint_auth_method="client_secret_post", + issuer="https://api.example.com", ) await provider._initialize() @@ -107,6 +111,7 @@ class TestClientCredentialsOAuthProvider: client_id="test-client-id", client_secret="test-client-secret", scope="read write", + issuer="https://api.example.com", ) provider.context.oauth_metadata = OAuthMetadata( issuer=AnyHttpUrl("https://api.example.com"), @@ -135,6 +140,7 @@ class TestClientCredentialsOAuthProvider: client_secret="test-client-secret", token_endpoint_auth_method="client_secret_post", scope="read write", + issuer="https://api.example.com", ) await provider._initialize() provider.context.oauth_metadata = OAuthMetadata( @@ -161,6 +167,7 @@ class TestClientCredentialsOAuthProvider: storage=mock_storage, client_id="test-client-id", client_secret="test-client-secret", + issuer="https://api.example.com", ) provider.context.oauth_metadata = OAuthMetadata( issuer=AnyHttpUrl("https://api.example.com"), @@ -192,6 +199,7 @@ class TestPrivateKeyJWTOAuthProvider: storage=mock_storage, client_id="test-client-id", assertion_provider=mock_assertion_provider, + issuer="https://api.example.com", ) # client_info is set during _initialize @@ -215,6 +223,7 @@ class TestPrivateKeyJWTOAuthProvider: client_id="test-client-id", assertion_provider=mock_assertion_provider, scope="read write", + issuer="https://auth.example.com", ) provider.context.oauth_metadata = OAuthMetadata( issuer=AnyHttpUrl("https://auth.example.com"), @@ -246,6 +255,7 @@ class TestPrivateKeyJWTOAuthProvider: storage=mock_storage, client_id="test-client-id", assertion_provider=mock_assertion_provider, + issuer="https://auth.example.com", ) provider.context.oauth_metadata = OAuthMetadata( issuer=AnyHttpUrl("https://auth.example.com"), @@ -436,6 +446,65 @@ async def test_provider_picks_its_configured_issuer_among_several_advertised_ser await flow.aclose() +@pytest.mark.parametrize("kind", ["secret", "jwt"]) +def test_constructing_without_issuer_is_deprecated(mock_storage: MockTokenStorage, kind: str) -> None: + """SDK-defined: leaving `issuer` out is allowed, and the provider says at construction that + token requests will follow whichever authorization server the MCP server advertises.""" + + async def assertion_provider(audience: str) -> str: + raise NotImplementedError + + with pytest.warns(MCPDeprecationWarning) as recorded: + if kind == "secret": + ClientCredentialsOAuthProvider( + server_url=_SERVER_URL, storage=mock_storage, client_id="c", client_secret="s" + ) + else: + PrivateKeyJWTOAuthProvider( + server_url=_SERVER_URL, storage=mock_storage, client_id="c", assertion_provider=assertion_provider + ) + + [warning] = recorded + assert warning.filename == __file__ + assert str(warning.message) == ( + "Omitting `issuer` is deprecated and it will be required in 3.0. Without it, the MCP server " + "decides which authorization server receives this client's credentials; pass " + "issuer= so they are only ever sent there." + ) + + +@pytest.mark.anyio +@pytest.mark.parametrize("kind", ["secret", "jwt"]) +async def test_without_issuer_the_exchange_follows_whichever_server_was_discovered( + mock_storage: MockTokenStorage, kind: str +) -> None: + """SDK-defined: with no `issuer` configured the token request is built from whatever metadata + discovery produced, as before.""" + + async def assertion_provider(audience: str) -> str: + return "jwt" + + with pytest.warns(MCPDeprecationWarning, match="Omitting `issuer` is deprecated"): + if kind == "secret": + provider: OAuthClientProvider = ClientCredentialsOAuthProvider( + server_url=_SERVER_URL, storage=mock_storage, client_id="c", client_secret="s" + ) + else: + provider = PrivateKeyJWTOAuthProvider( + server_url=_SERVER_URL, storage=mock_storage, client_id="c", assertion_provider=assertion_provider + ) + flow = provider.async_auth_flow(httpx2.Request("POST", _SERVER_URL)) + + token_request = await _answer_discovery( + flow, + authorization_server="https://elsewhere.example.com", + metadata=_metadata_for("https://elsewhere.example.com"), + ) + + assert (token_request.method, str(token_request.url)) == ("POST", "https://elsewhere.example.com/token") + await flow.aclose() + + def test_an_issuer_that_is_not_an_http_url_is_rejected_at_construction(mock_storage: MockTokenStorage) -> None: """SDK-defined: `issuer=` is the authorization server's issuer URL; anything else is a configuration error on both machine-to-machine providers.""" diff --git a/tests/docs_src/test_oauth_clients.py b/tests/docs_src/test_oauth_clients.py index a4ec05d9..db876139 100644 --- a/tests/docs_src/test_oauth_clients.py +++ b/tests/docs_src/test_oauth_clients.py @@ -105,6 +105,7 @@ async def test_the_one_more_provider_is_private_key_jwt() -> None: storage=tutorial002.InMemoryTokenStorage(), client_id="reporting-agent", assertion_provider=static_assertion_provider("a.prebuilt.jwt"), + issuer="http://localhost:9000", ) assert isinstance(provider, OAuthClientProvider) assert isinstance(provider, httpx2.Auth) diff --git a/tests/interaction/auth/test_lifecycle.py b/tests/interaction/auth/test_lifecycle.py index 8f45a015..610db62e 100644 --- a/tests/interaction/auth/test_lifecycle.py +++ b/tests/interaction/auth/test_lifecycle.py @@ -373,6 +373,7 @@ async def test_client_credentials_provider_obtains_a_token_without_an_authorize_ client_id="m2m-client", client_secret="m2m-secret", scope="mcp", + issuer=BASE_URL, ) with anyio.fail_after(5): @@ -424,6 +425,7 @@ async def test_private_key_jwt_provider_authenticates_the_token_request_with_an_ client_id="m2m-jwt-client", assertion_provider=assertion_provider, scope="mcp", + issuer=BASE_URL, ) with anyio.fail_after(5): From 10dc17363762b5438368f2d8969d8cf3046d7673 Mon Sep 17 00:00:00 2001 From: Max <224885523+maxisbey@users.noreply.github.com> Date: Fri, 4 Sep 2026 17:11:00 +0100 Subject: [PATCH 07/20] Exercise the SEP-2575 stateless probes and SEP-2243 resource/prompt headers in the conformance fixtures (#3442) --- .github/actions/conformance/client.py | 23 ++++++++++--- .../simple-chatbot/{README.MD => README.md} | 0 .../mcp_everything_server/server.py | 32 ++++++++++++++++++- 3 files changed, 49 insertions(+), 6 deletions(-) rename examples/clients/simple-chatbot/{README.MD => README.md} (100%) diff --git a/.github/actions/conformance/client.py b/.github/actions/conformance/client.py index 18e59a8a..daad2769 100644 --- a/.github/actions/conformance/client.py +++ b/.github/actions/conformance/client.py @@ -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]: diff --git a/examples/clients/simple-chatbot/README.MD b/examples/clients/simple-chatbot/README.md similarity index 100% rename from examples/clients/simple-chatbot/README.MD rename to examples/clients/simple-chatbot/README.md diff --git a/examples/servers/everything-server/mcp_everything_server/server.py b/examples/servers/everything-server/mcp_everything_server/server.py index 5f219461..836032a1 100644 --- a/examples/servers/everything-server/mcp_everything_server/server.py +++ b/examples/servers/everything-server/mcp_everything_server/server.py @@ -12,7 +12,7 @@ from typing import Annotated, Any import click from mcp.server import ServerRequestContext -from mcp.server.mcpserver import Context, MCPServer, RequestStateSecurity +from mcp.server.mcpserver import Context, Elicit, ElicitationResult, MCPServer, RequestStateSecurity, Resolve from mcp.server.mcpserver.exceptions import ToolError from mcp.server.mcpserver.prompts.base import Prompt, UserMessage from mcp.server.streamable_http import EventCallback, EventMessage, EventStore @@ -350,6 +350,9 @@ def test_x_mcp_header( return f"region={region}" +# SEP-2575 server-stateless diagnostics (the conformance scenario probes these tools by name) + + @mcp.tool() async def test_missing_capability(ctx: Context) -> str: """Tests that a handler-raised MISSING_REQUIRED_CLIENT_CAPABILITY surfaces as a top-level JSON-RPC error. @@ -370,6 +373,33 @@ async def test_missing_capability(ctx: Context) -> str: return "Client declared sampling capability; proceeding." +def _ask_stream_probe() -> Elicit[UserResponse]: + return Elicit("The stateless streaming probe asks for a word", UserResponse) + + +@mcp.tool() +async def test_streaming_elicitation( + answer: Annotated[ElicitationResult[UserResponse], Resolve(_ask_stream_probe)], +) -> str: + """A tool that needs elicitation, asked through a resolver (SEP-2575 / SEP-2322). + + On 2026-07-28 the question is returned as an InputRequiredResult rather than sent + on the response stream; on earlier versions it is a mid-call elicitation request. + """ + return f"elicitation {answer.action}" + + +@mcp.tool() +async def test_logging_tool(ctx: Context) -> str: + """Logs once on the request-scoped channel (SEP-2575). + + On 2026-07-28 the message is delivered only when the request's `_meta` sets + `io.modelcontextprotocol/logLevel`. + """ + await ctx.info("test_logging_tool ran") # pyright: ignore[reportDeprecated] + return "logged through the request-scoped, logLevel-gated channel" + + # SEP-2322 InputRequiredResult fixtures (multi-round-trip / ephemeral workflow) NAME_SCHEMA = {"type": "object", "properties": {"name": {"type": "string"}}, "required": ["name"]} From 2e0a18199ad728d15712af1c6653741074b963d2 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 4 Sep 2026 20:17:00 +0100 Subject: [PATCH 08/20] Bump the github-actions group with 6 updates (#3424) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/claude.yml | 4 ++-- .github/workflows/conformance.yml | 12 +++++----- .github/workflows/deploy-docs.yml | 4 ++-- .github/workflows/docs-preview.yml | 8 +++---- .github/workflows/publish-pypi.yml | 4 ++-- .github/workflows/require-linked-issue.yml | 4 ++-- .github/workflows/shared.yml | 26 +++++++++++----------- .github/workflows/zizmor.yml | 4 ++-- 8 files changed, 33 insertions(+), 33 deletions(-) diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml index 859055da..05e4964c 100644 --- a/.github/workflows/claude.yml +++ b/.github/workflows/claude.yml @@ -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 diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml index 65b014b6..64c334ef 100644 --- a/.github/workflows/conformance.yml +++ b/.github/workflows/conformance.yml @@ -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 diff --git a/.github/workflows/deploy-docs.yml b/.github/workflows/deploy-docs.yml index 28ae351f..eaf1c167 100644 --- a/.github/workflows/deploy-docs.yml +++ b/.github/workflows/deploy-docs.yml @@ -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 diff --git a/.github/workflows/docs-preview.yml b/.github/workflows/docs-preview.yml index 278aa442..3be65101 100644 --- a/.github/workflows/docs-preview.yml +++ b/.github/workflows/docs-preview.yml @@ -60,7 +60,7 @@ jobs: steps: - 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; @@ -117,13 +117,13 @@ 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 @@ -195,7 +195,7 @@ jobs: pull-requests: write steps: - 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 }} diff --git a/.github/workflows/publish-pypi.yml b/.github/workflows/publish-pypi.yml index 946ac3db..73b8f87a 100644 --- a/.github/workflows/publish-pypi.yml +++ b/.github/workflows/publish-pypi.yml @@ -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 diff --git a/.github/workflows/require-linked-issue.yml b/.github/workflows/require-linked-issue.yml index 31ccdc3e..309978e8 100644 --- a/.github/workflows/require-linked-issue.yml +++ b/.github/workflows/require-linked-issue.yml @@ -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'); diff --git a/.github/workflows/shared.yml b/.github/workflows/shared.yml index 5d363730..3dd8dd8f 100644 --- a/.github/workflows/shared.yml +++ b/.github/workflows/shared.yml @@ -13,7 +13,7 @@ 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 @@ -22,14 +22,14 @@ jobs: - name: Install uv id: setup-uv continue-on-error: true - uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 + uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 with: enable-cache: true version: 0.9.5 - name: 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,7 +41,7 @@ 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 @@ -75,7 +75,7 @@ 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 @@ -84,14 +84,14 @@ jobs: - name: Install uv id: setup-uv continue-on-error: true - uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 + uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 with: enable-cache: true version: 0.9.5 - name: 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,7 +121,7 @@ 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 @@ -130,14 +130,14 @@ jobs: - name: Install uv id: setup-uv continue-on-error: true - uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 + uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 with: enable-cache: true version: 0.9.5 - name: 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,7 +161,7 @@ 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 @@ -170,14 +170,14 @@ jobs: - name: Install uv id: setup-uv continue-on-error: true - uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 + uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 with: enable-cache: true version: 0.9.5 - name: 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 diff --git a/.github/workflows/zizmor.yml b/.github/workflows/zizmor.yml index f66ff742..598e76df 100644 --- a/.github/workflows/zizmor.yml +++ b/.github/workflows/zizmor.yml @@ -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 From a2dab7e2dd7edb00e4dfdc7075dd739cbe13f82b Mon Sep 17 00:00:00 2001 From: Max <224885523+maxisbey@users.noreply.github.com> Date: Fri, 4 Sep 2026 21:30:04 +0100 Subject: [PATCH 09/20] Move the docs-preview workflow scripts out of the YAML into .github/scripts (#3446) --- .github/scripts/docs_preview.js | 108 ++++++++++++++ .github/scripts/docs_preview.test.js | 212 +++++++++++++++++++++++++++ .github/workflows/docs-preview.yml | 108 +++----------- .github/workflows/shared.yml | 6 +- 4 files changed, 342 insertions(+), 92 deletions(-) create mode 100644 .github/scripts/docs_preview.js create mode 100644 .github/scripts/docs_preview.test.js diff --git a/.github/scripts/docs_preview.js b/.github/scripts/docs_preview.js new file mode 100644 index 00000000..5d18a500 --- /dev/null +++ b/.github/scripts/docs_preview.js @@ -0,0 +1,108 @@ +// 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 = ''; +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') { + // 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)); +} + +// 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 }; diff --git a/.github/scripts/docs_preview.test.js b/.github/scripts/docs_preview.test.js new file mode 100644 index 00000000..1b6d5753 --- /dev/null +++ b/.github/scripts/docs_preview.test.js @@ -0,0 +1,212 @@ +// 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 HEAD = 'e4dfda7baa127ab00ebcd1d5324560cbe3cdfe42'; +const MARKER = ''; + +// `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' }, + }, + { + 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); + }); +} + +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 ──────────────────────────────────────────────────────────────── + +function pushed(number, sender) { + return { eventName: 'pull_request_target', actor: sender, payload: { action: 'synchronize', pull_request: { number, head: { sha: HEAD } }, 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, 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 }) => { + 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); return { data: { number: pull_number, state: world.pr.state, head: { sha: HEAD } } }; }, + }, + 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; +} diff --git a/.github/workflows/docs-preview.yml b/.github/workflows/docs-preview.yml index 3be65101..7236f2b6 100644 --- a/.github/workflows/docs-preview.yml +++ b/.github/workflows/docs-preview.yml @@ -8,7 +8,8 @@ name: Docs Preview # 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. +# runner with the Cloudflare token. `authorize` and `comment` run +# .github/scripts/docs_preview.js, checked out from the default branch only. # # Required configuration: # - secrets.CLOUDFLARE_API_TOKEN (scope: Account → Cloudflare Pages → Edit) @@ -58,57 +59,19 @@ 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: + persist-credentials: false + sparse-checkout: .github/scripts + - name: Determine authorization id: check 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 @@ -192,8 +155,15 @@ 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: + persist-credentials: false + sparse-checkout: .github/scripts + - name: Post or update preview comment uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 env: @@ -206,45 +176,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 = ''; - - 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 }); diff --git a/.github/workflows/shared.yml b/.github/workflows/shared.yml index 3dd8dd8f..a223c7aa 100644 --- a/.github/workflows/shared.yml +++ b/.github/workflows/shared.yml @@ -43,9 +43,9 @@ jobs: - 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: | From 5fd3abc5481de7891a2c27145e8289bca3f0da3a Mon Sep 17 00:00:00 2001 From: Max <224885523+maxisbey@users.noreply.github.com> Date: Fri, 4 Sep 2026 21:56:58 +0100 Subject: [PATCH 10/20] Skip automatic docs previews for fork PRs and drop the setup-uv retry steps (#3445) --- .github/scripts/docs_preview.js | 27 +++++++++++------ .github/scripts/docs_preview.test.js | 37 ++++++++++++++++++++--- .github/workflows/docs-preview.yml | 39 ++++++++++++++---------- .github/workflows/shared.yml | 44 ---------------------------- 4 files changed, 74 insertions(+), 73 deletions(-) diff --git a/.github/scripts/docs_preview.js b/.github/scripts/docs_preview.js index 5d18a500..fae56022 100644 --- a/.github/scripts/docs_preview.js +++ b/.github/scripts/docs_preview.js @@ -23,15 +23,24 @@ async function authorize({ github, context, core }) { 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}`); + 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. diff --git a/.github/scripts/docs_preview.test.js b/.github/scripts/docs_preview.test.js index 1b6d5753..cb178357 100644 --- a/.github/scripts/docs_preview.test.js +++ b/.github/scripts/docs_preview.test.js @@ -12,6 +12,7 @@ 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 = ''; @@ -37,6 +38,26 @@ const authorizeScenarios = [ 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'), @@ -70,6 +91,7 @@ for (const s of authorizeScenarios) { 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); }); } @@ -146,8 +168,10 @@ test('comment: a build or deploy that did not succeed is reported with the short // ── Harness ──────────────────────────────────────────────────────────────── -function pushed(number, sender) { - return { eventName: 'pull_request_target', actor: sender, payload: { action: 'synchronize', pull_request: { number, head: { sha: HEAD } }, sender: { login: sender } } }; +// `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 } } } }; @@ -173,7 +197,7 @@ async function runComment(world, env, actor) { // ── A tiny in-memory GitHub ──────────────────────────────────────────────── function makeWorld({ pr, comments = [] }) { - const world = { pr: { state: 'open', ...pr }, comments: [], writes: [], failPermissionLookup: false, nextCommentId: 100 }; + 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 }); @@ -183,6 +207,7 @@ function makeWorld({ pr, comments = [] }) { 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'); @@ -190,7 +215,11 @@ function makeWorld({ pr, comments = [] }) { }, }, pulls: { - get: async ({ pull_number }) => { checkPr(pull_number); return { data: { number: pull_number, state: world.pr.state, head: { sha: HEAD } } }; }, + 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 } })) }; }, diff --git a/.github/workflows/docs-preview.yml b/.github/workflows/docs-preview.yml index 7236f2b6..e54f5d16 100644 --- a/.github/workflows/docs-preview.yml +++ b/.github/workflows/docs-preview.yml @@ -4,12 +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. `authorize` and `comment` run -# .github/scripts/docs_preview.js, checked out from the default branch only. +# 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) @@ -62,6 +66,7 @@ jobs: - 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 @@ -88,19 +93,20 @@ jobs: - name: Install uv 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 @@ -161,6 +167,7 @@ jobs: - 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 diff --git a/.github/workflows/shared.yml b/.github/workflows/shared.yml index a223c7aa..ef6eb25a 100644 --- a/.github/workflows/shared.yml +++ b/.github/workflows/shared.yml @@ -17,18 +17,7 @@ jobs: 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@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 - with: - enable-cache: true - version: 0.9.5 - - - name: Install uv (retry) - if: steps.setup-uv.outcome == 'failure' uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 with: enable-cache: true @@ -79,18 +68,7 @@ jobs: 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@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 - with: - enable-cache: true - version: 0.9.5 - - - name: Install uv (retry) - if: steps.setup-uv.outcome == 'failure' uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 with: enable-cache: true @@ -125,18 +103,7 @@ jobs: 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@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 - with: - enable-cache: true - version: 0.9.5 - - - name: Install uv (retry) - if: steps.setup-uv.outcome == 'failure' uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 with: enable-cache: true @@ -165,18 +132,7 @@ jobs: 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@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 - with: - enable-cache: true - version: 0.9.5 - - - name: Install uv (retry) - if: steps.setup-uv.outcome == 'failure' uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 with: enable-cache: true From c6762e821c527925f0b617db216d00fbd1d0bf99 Mon Sep 17 00:00:00 2001 From: Max <224885523+maxisbey@users.noreply.github.com> Date: Fri, 4 Sep 2026 23:43:38 +0100 Subject: [PATCH 11/20] Follow redirects only within the MCP endpoint's origin (#3397) --- docs/client/oauth-clients.md | 2 + docs/client/transports.md | 28 +- docs/migration.md | 14 +- docs/run/asgi.md | 2 +- docs/run/deploy.md | 17 ++ docs_src/client_transports/tutorial003.py | 1 - docs_src/identity_assertion/tutorial001.py | 2 +- docs_src/oauth_clients/tutorial001.py | 2 +- docs_src/oauth_clients/tutorial002.py | 2 +- .../mcp_simple_auth_client/main.py | 2 +- .../simple-tool/mcp_simple_tool/server.py | 5 +- .../clients/identity_assertion_client.py | 2 +- examples/snippets/clients/oauth_client.py | 2 +- .../auth/extensions/identity_assertion.py | 9 +- src/mcp/client/auth/oauth2.py | 13 +- src/mcp/client/auth/utils.py | 11 +- src/mcp/client/sse.py | 28 +- src/mcp/client/streamable_http.py | 60 +++- src/mcp/shared/_httpx_utils.py | 219 +++++++++++---- tests/client/test_auth.py | 139 ++++++++-- tests/client/test_http_unicode.py | 8 +- tests/client/test_streamable_http.py | 222 ++++++++++++++- tests/shared/test_httpx_utils.py | 259 +++++++++++++++++- tests/shared/test_sse.py | 115 +++++--- tests/shared/test_streamable_http.py | 56 ++-- 25 files changed, 1022 insertions(+), 198 deletions(-) diff --git a/docs/client/oauth-clients.md b/docs/client/oauth-clients.md index fe9f8111..96ead3c7 100644 --- a/docs/client/oauth-clients.md +++ b/docs/client/oauth-clients.md @@ -83,6 +83,8 @@ The first time `Client` sends a request, the server answers `401`. The provider After that it is quiet. Tokens come out of storage, an expired access token is refreshed with the refresh token, and only when none of that works does it run the flow again. +One transport rule applies to all of these requests: like the MCP request they run inside, they follow a redirect only when it stays on the same origin and keeps the method (a trailing-slash 307/308, say), and treat any other redirect as that URL not answering. + You wrote none of it. Two keyword arguments remain (`client_metadata_url` and `validate_resource_url`), and this file needs neither. `client_metadata_url` is the one worth knowing about; it gets its own section below. ### Try it diff --git a/docs/client/transports.md b/docs/client/transports.md index afb33caf..1a494496 100644 --- a/docs/client/transports.md +++ b/docs/client/transports.md @@ -29,7 +29,7 @@ Pass a URL string and you get **Streamable HTTP**, the transport you deploy behi --8<-- "docs_src/client_transports/tutorial002.py" ``` -That is the whole production client. `Client` wraps the URL in `streamable_http_client(...)` for you, on top of an `httpx2.AsyncClient` configured the way MCP needs: `follow_redirects=True`, a 30-second timeout for connect/write/pool, and a 300-second read timeout because the server may hold a response stream open. +That is the whole production client. `Client` wraps the URL in `streamable_http_client(...)` for you, on top of an `httpx2.AsyncClient` configured the way MCP needs: a 30-second timeout for connect/write/pool, and a 300-second read timeout because the server may hold a response stream open. !!! check A `Client` you have constructed is **not** connected. Construction only picks the transport; @@ -45,7 +45,7 @@ That is the whole production client. `Client` wraps the URL in `streamable_http_ The moment you need an `Authorization` header, a cookie, a proxy, mTLS, or a different timeout, build the `httpx2.AsyncClient` yourself and hand it to `streamable_http_client`: -```python title="client.py" hl_lines="8-14" +```python title="client.py" hl_lines="8-13" --8<-- "docs_src/client_transports/tutorial003.py" ``` @@ -75,9 +75,30 @@ environment variables or pass an explicit `verify=ssl_context` to your `httpx2.A !!! info `httpx2` keeps the familiar `httpx` API, so if you know `httpx` you already know how to do auth, proxies, event hooks, retries and connection limits here. The SDK adds nothing on top and takes - nothing away. It is also where OAuth plugs in: + nothing away, except [redirect handling](#redirects). It is also where OAuth plugs in: `httpx2.AsyncClient(auth=OAuthClientProvider(...))`. That whole flow is **[OAuth clients](oauth-clients.md)**. +### Redirects + +The transport connects to the URL you gave it, and only that origin. + +* A `307`/`308` redirect that stays on the same scheme, host and port is followed, and so is `http://` → `https://` on the same host. That covers the usual `/mcp` → `/mcp/` trailing-slash redirect. +* A redirect anywhere else is **not** followed. The call fails with: + + ```text + MCPError: Redirect to https://other.example.com/mcp not followed; use that URL as the endpoint if it is the intended server + ``` + + If that URL is the server you meant, put it in your config. If it isn't, the server or a proxy in front of it is misconfigured. + +This holds for any `httpx2.AsyncClient` you pass in: its `follow_redirects` setting is not consulted for MCP requests, in either direction. The SDK's OAuth providers apply the same rule to their own requests. + +!!! tip + `Redirect to http://… not followed: it would downgrade this HTTPS endpoint to plain HTTP` means the + server sits behind a TLS-terminating proxy it doesn't know about and is issuing `http://` redirects. + That is fixed on the server (**[Deploy & scale](../run/deploy.md#behind-a-tls-terminating-proxy)**), + or by using the exact `https://…/` URL the message suggests. + ## stdio A **stdio** server is a subprocess. The client launches it, writes JSON-RPC to its stdin and reads JSON-RPC from its stdout. It is how a desktop host runs a server on your machine: a host *is* this code plus a UI, and **[Connect to a real host](../get-started/real-host.md)** is the same relationship seen from the host's side, as a config file. @@ -115,6 +136,7 @@ A **transport** is any async context manager that yields a `(read, write)` pair * `Client(mcp)` (the server object) connects in memory. Use it for tests and for embedding. * `Client("http://.../mcp")` (a URL) connects over Streamable HTTP, the production transport. * Headers, auth, proxies and timeouts belong on an `httpx2.AsyncClient` you pass to `streamable_http_client(url, http_client=...)`. There is no `headers=` keyword. +* Redirects are followed only within the URL's own origin (a trailing-slash `307`/`308`), plus `http`→`https` on the same host. Anything else fails with `Redirect to … not followed`; configure the final URL. * stdio is `Client(StdioServerParameters(...))`. Wrap it in `stdio_client(...)` yourself only to redirect the child's stderr. * The subprocess gets an allow-listed environment, not yours; `env=` adds to it. * A transport is anything you can `async with x as (read, write)`. `Client` hands anything that isn't a server object, a URL or `StdioServerParameters` straight to that protocol. diff --git a/docs/migration.md b/docs/migration.md index e49cb3b8..03f04675 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -119,7 +119,7 @@ them: ```python import httpx -http_client = httpx.AsyncClient(follow_redirects=True) +http_client = httpx.AsyncClient(timeout=httpx.Timeout(30, read=300)) ``` **After (v2):** @@ -127,7 +127,7 @@ http_client = httpx.AsyncClient(follow_redirects=True) ```python import httpx2 -http_client = httpx2.AsyncClient(follow_redirects=True) +http_client = httpx2.AsyncClient(timeout=httpx2.Timeout(30, read=300)) ``` `httpx2` is API-compatible with `httpx`, so usually only the import name @@ -2092,7 +2092,6 @@ http_client = httpx2.AsyncClient( headers={"Authorization": "Bearer token"}, timeout=httpx2.Timeout(30, read=300), auth=my_auth, - follow_redirects=True, ) async with http_client: @@ -2103,11 +2102,11 @@ async with http_client: ... ``` -v1's internal client set `follow_redirects=True`; set it explicitly when supplying your own `httpx2.AsyncClient` to preserve that behavior. +v1's internal client set `follow_redirects=True`. You don't need it on your own client: the transport follows a method-preserving redirect within the endpoint's origin (a trailing-slash 307/308, say) itself, and does not follow one anywhere else, whatever the client is configured to do. `streamable_http_client` itself keeps a small signature — `streamable_http_client(url, *, http_client=None, terminate_on_close=True)` — and now yields a 2-tuple (next section). The removed function's other parameters map onto the client you build: -- `headers`, `timeout`, `sse_read_timeout`, `auth`: set them on the `httpx2.AsyncClient` as above. `streamablehttp_client` defaulted to `httpx.Timeout(30, read=300)`; a bare `httpx2.AsyncClient()` falls back to httpx2's flat 5-second timeout, too short for the long-lived GET stream, so set `timeout=httpx2.Timeout(30, read=300)` (as shown) to keep v1's values. Omitting `http_client` still gives you a default client with those timeouts and `follow_redirects=True`. +- `headers`, `timeout`, `sse_read_timeout`, `auth`: set them on the `httpx2.AsyncClient` as above. `streamablehttp_client` defaulted to `httpx.Timeout(30, read=300)`; a bare `httpx2.AsyncClient()` falls back to httpx2's flat 5-second timeout, too short for the long-lived GET stream, so set `timeout=httpx2.Timeout(30, read=300)` (as shown) to keep v1's values. Omitting `http_client` still gives you a default client with those timeouts. - `httpx_client_factory`: gone with no replacement — call your factory yourself and pass the result as `http_client`. - `terminate_on_close`: unchanged (default `True`). @@ -2151,10 +2150,7 @@ async def capture_session_id(response: httpx2.Response) -> None: if session_id: captured_session_ids.append(session_id) -http_client = httpx2.AsyncClient( - event_hooks={"response": [capture_session_id]}, - follow_redirects=True, -) +http_client = httpx2.AsyncClient(event_hooks={"response": [capture_session_id]}) async with http_client: async with streamable_http_client(url, http_client=http_client) as (read_stream, write_stream): diff --git a/docs/run/asgi.md b/docs/run/asgi.md index 2eca9273..dd116be3 100644 --- a/docs/run/asgi.md +++ b/docs/run/asgi.md @@ -94,7 +94,7 @@ That trailing `/mcp` is `streamable_http_path`. Set it to `"/"` and the mount pr --8<-- "docs_src/asgi/tutorial004.py" ``` -Now clients connect to `/notes`, not `/notes/mcp`. +Now clients connect to `/notes/`, not `/notes/mcp`. ## CORS for browser clients diff --git a/docs/run/deploy.md b/docs/run/deploy.md index 24f25c20..c08bc158 100644 --- a/docs/run/deploy.md +++ b/docs/run/deploy.md @@ -42,6 +42,22 @@ Deployed behind a real hostname, that same default rejects **every request** unt deployed server that refuses every connection is a Host allowlist until proven otherwise. **[Troubleshooting](../troubleshooting.md)** starts here too. +## Behind a TLS-terminating proxy + +If TLS ends at a proxy (an ingress, a load balancer, Caddy, nginx) and uvicorn serves plain HTTP behind it, tell uvicorn to trust the proxy's `X-Forwarded-*` headers: + +```console +uvicorn server:app --proxy-headers --forwarded-allow-ips='' +``` + +Without that, the app believes it is being served over `http://`, and any redirect it issues (the usual one is `/mcp` → `/mcp/`) points at `http://…`. The Python client refuses to follow an HTTPS endpoint to plain HTTP and says so: + +```text +MCPError: Redirect to http://mcp.example.com/mcp/ not followed: it would downgrade this HTTPS endpoint to plain HTTP. +``` + +The client-side stopgap is to configure the exact URL the server serves (`https://mcp.example.com/mcp/`, slash included) so no redirect happens. The fix is the flag above. `FORWARDED_ALLOW_IPS` is the environment-variable spelling; `*` trusts every hop, which is only right when nothing but the proxy can reach uvicorn. + ## Workers, and who has to be sticky Once the hostname answers, put more than one worker behind it. There is no SDK knob for that; you scale a Starlette app the way you scale any ASGI app, by handing the object to something that knows how to fork: @@ -165,6 +181,7 @@ An `MCPServer` is a protocol implementation, not an application server. The depl ## Recap * Out of the box the app answers only requests addressed to localhost. `transport_security=TransportSecuritySettings(allowed_hosts=[...], allowed_origins=[...])` is the go-live gate: until you pass it, every request behind a real hostname is a `421` and the reason is only in the server's log. +* Behind a TLS-terminating proxy, run uvicorn with `--proxy-headers --forwarded-allow-ips=...`, or its redirects point at `http://` and the client refuses them. * On 2026-07-28 there is no session and nothing for a load balancer to be sticky on. `stateless_http=True` is a legacy-only knob because a modern request is routed and answered before that flag is ever read. * The default `requestState` key is `os.urandom(32)`, minted per process. A multi-round-trip retry that reaches a different worker fails with `-32602` *"Invalid or expired requestState"*. * The fix is `RequestStateSecurity(keys=[...])` **and** the same server name on every instance. The name is the token's default audience claim. Same keys, same name. diff --git a/docs_src/client_transports/tutorial003.py b/docs_src/client_transports/tutorial003.py index 4df055e2..488737dc 100644 --- a/docs_src/client_transports/tutorial003.py +++ b/docs_src/client_transports/tutorial003.py @@ -8,7 +8,6 @@ async def main() -> None: async with httpx2.AsyncClient( headers={"Authorization": "Bearer ..."}, timeout=httpx2.Timeout(30.0, read=300.0), - follow_redirects=True, ) as http_client: transport = streamable_http_client("http://localhost:8000/mcp", http_client=http_client) async with Client(transport) as client: diff --git a/docs_src/identity_assertion/tutorial001.py b/docs_src/identity_assertion/tutorial001.py index afcd5378..24bc2657 100644 --- a/docs_src/identity_assertion/tutorial001.py +++ b/docs_src/identity_assertion/tutorial001.py @@ -62,7 +62,7 @@ oauth = IdentityAssertionOAuthProvider( async def main() -> None: - async with httpx2.AsyncClient(auth=oauth, follow_redirects=True) as http_client: + async with httpx2.AsyncClient(auth=oauth) as http_client: transport = streamable_http_client("http://localhost:8001/mcp", http_client=http_client) async with Client(transport) as client: result = await client.list_tools() diff --git a/docs_src/oauth_clients/tutorial001.py b/docs_src/oauth_clients/tutorial001.py index d150dc5d..6e01553d 100644 --- a/docs_src/oauth_clients/tutorial001.py +++ b/docs_src/oauth_clients/tutorial001.py @@ -55,7 +55,7 @@ oauth = OAuthClientProvider( async def main() -> None: - async with httpx2.AsyncClient(auth=oauth, follow_redirects=True) as http_client: + async with httpx2.AsyncClient(auth=oauth) as http_client: transport = streamable_http_client("http://localhost:8001/mcp", http_client=http_client) async with Client(transport) as client: result = await client.list_tools() diff --git a/docs_src/oauth_clients/tutorial002.py b/docs_src/oauth_clients/tutorial002.py index d6323c82..507ee4bf 100644 --- a/docs_src/oauth_clients/tutorial002.py +++ b/docs_src/oauth_clients/tutorial002.py @@ -35,7 +35,7 @@ oauth = ClientCredentialsOAuthProvider( async def main() -> None: - async with httpx2.AsyncClient(auth=oauth, follow_redirects=True) as http_client: + async with httpx2.AsyncClient(auth=oauth) as http_client: transport = streamable_http_client("http://localhost:8001/mcp", http_client=http_client) async with Client(transport) as client: result = await client.list_tools() diff --git a/examples/clients/simple-auth-client/mcp_simple_auth_client/main.py b/examples/clients/simple-auth-client/mcp_simple_auth_client/main.py index a190b899..b04e6fb5 100644 --- a/examples/clients/simple-auth-client/mcp_simple_auth_client/main.py +++ b/examples/clients/simple-auth-client/mcp_simple_auth_client/main.py @@ -233,7 +233,7 @@ class SimpleAuthClient: await self._run_session(read_stream, write_stream) else: print("📡 Opening StreamableHTTP transport connection with auth...") - async with httpx2.AsyncClient(auth=oauth_auth, follow_redirects=True) as custom_client: + async with httpx2.AsyncClient(auth=oauth_auth) as custom_client: async with streamable_http_client(url=self.server_url, http_client=custom_client) as ( read_stream, write_stream, diff --git a/examples/servers/simple-tool/mcp_simple_tool/server.py b/examples/servers/simple-tool/mcp_simple_tool/server.py index a43dd0f7..ac99110b 100644 --- a/examples/servers/simple-tool/mcp_simple_tool/server.py +++ b/examples/servers/simple-tool/mcp_simple_tool/server.py @@ -1,15 +1,16 @@ import anyio import click +import httpx2 import mcp.types as types from mcp.server import Server, ServerRequestContext -from mcp.shared._httpx_utils import create_mcp_http_client async def fetch_website( url: str, ) -> list[types.ContentBlock]: headers = {"User-Agent": "MCP Test Server (github.com/modelcontextprotocol/python-sdk)"} - async with create_mcp_http_client(headers=headers) as client: + timeout = httpx2.Timeout(30, read=300) + async with httpx2.AsyncClient(headers=headers, timeout=timeout, follow_redirects=True) as client: response = await client.get(url) response.raise_for_status() return [types.TextContent(type="text", text=response.text)] diff --git a/examples/snippets/clients/identity_assertion_client.py b/examples/snippets/clients/identity_assertion_client.py index 19cde274..8c80f289 100644 --- a/examples/snippets/clients/identity_assertion_client.py +++ b/examples/snippets/clients/identity_assertion_client.py @@ -66,7 +66,7 @@ async def main() -> None: scope="user", ) - async with httpx2.AsyncClient(auth=oauth_auth, follow_redirects=True) as http_client: + async with httpx2.AsyncClient(auth=oauth_auth) as http_client: async with streamable_http_client("http://localhost:8001/mcp", http_client=http_client) as (read, write): async with ClientSession(read, write) as session: await session.initialize() diff --git a/examples/snippets/clients/oauth_client.py b/examples/snippets/clients/oauth_client.py index 58c542ea..11e0f5f9 100644 --- a/examples/snippets/clients/oauth_client.py +++ b/examples/snippets/clients/oauth_client.py @@ -72,7 +72,7 @@ async def main(): callback_handler=handle_callback, ) - async with httpx2.AsyncClient(auth=oauth_auth, follow_redirects=True) as custom_client: + async with httpx2.AsyncClient(auth=oauth_auth) as custom_client: async with streamable_http_client("http://localhost:8001/mcp", http_client=custom_client) as (read, write): async with ClientSession(read, write) as session: await session.initialize() diff --git a/src/mcp/client/auth/extensions/identity_assertion.py b/src/mcp/client/auth/extensions/identity_assertion.py index 17738a70..35e48c03 100644 --- a/src/mcp/client/auth/extensions/identity_assertion.py +++ b/src/mcp/client/auth/extensions/identity_assertion.py @@ -39,6 +39,7 @@ from mcp.client.auth.utils import ( union_scopes, validate_metadata_issuer, ) +from mcp.shared._httpx_utils import RedirectAwareAuth, redirect_note from mcp.shared.auth import JWT_BEARER_GRANT_TYPE, OAuthClientInformationFull, OAuthToken from mcp.shared.auth_utils import calculate_token_expiry, resource_url_from_server_url @@ -56,7 +57,7 @@ def _origin(url: str) -> tuple[str, str, int | None]: return (parsed.scheme, parsed.hostname or "", port) -class IdentityAssertionOAuthProvider(httpx2.Auth): +class IdentityAssertionOAuthProvider(RedirectAwareAuth): """`httpx2.Auth` for the SEP-990 ID-JAG flow (RFC 7523 jwt-bearer grant) against a configured AS. The authorization server `issuer` is fixed at construction; metadata is fetched from its @@ -159,7 +160,7 @@ class IdentityAssertionOAuthProvider(httpx2.Auth): data["client_secret"] = self._client.client_secret return httpx2.Request("POST", self._token_endpoint, data=data, headers=headers) - async def async_auth_flow(self, request: httpx2.Request) -> AsyncGenerator[httpx2.Request, httpx2.Response]: + async def _auth_flow(self, request: httpx2.Request) -> AsyncGenerator[httpx2.Request, httpx2.Response]: async with self._lock: if not self._initialized: self._tokens = await self._storage.get_tokens() @@ -201,7 +202,9 @@ class IdentityAssertionOAuthProvider(httpx2.Auth): token_response = yield self._build_token_request(scope_to_request, assertion) if token_response.status_code != 200: body = (await token_response.aread()).decode(errors="replace") - raise OAuthTokenError(f"Token exchange failed ({token_response.status_code}): {body}") + raise OAuthTokenError( + f"Token exchange failed ({token_response.status_code}){redirect_note(token_response)}: {body}" + ) tokens = await handle_token_response_scopes(token_response) if tokens.scope is None: tokens.scope = scope_to_request diff --git a/src/mcp/client/auth/oauth2.py b/src/mcp/client/auth/oauth2.py index 78d6f62e..85882089 100644 --- a/src/mcp/client/auth/oauth2.py +++ b/src/mcp/client/auth/oauth2.py @@ -42,6 +42,7 @@ from mcp.client.auth.utils import ( validate_authorization_response_iss, validate_metadata_issuer, ) +from mcp.shared._httpx_utils import RedirectAwareAuth, redirect_note from mcp.shared.auth import ( AuthorizationCodeResult, OAuthClientInformationFull, @@ -287,7 +288,7 @@ def _origin_issuer(server_url: str) -> str: return str(_ORIGIN_URL.validate_python(f"{parsed.scheme}://{parsed.netloc}")) -class OAuthClientProvider(httpx2.Auth): +class OAuthClientProvider(RedirectAwareAuth): """OAuth2 authentication for httpx2. Handles OAuth flow with automatic client registration and token storage. @@ -480,7 +481,9 @@ class OAuthClientProvider(httpx2.Auth): if response.status_code not in {200, 201}: body = await response.aread() body_text = body.decode("utf-8") - raise OAuthTokenError(f"Token exchange failed ({response.status_code}): {body_text}") + raise OAuthTokenError( + f"Token exchange failed ({response.status_code}){redirect_note(response)}: {body_text}" + ) # Parse and validate response with scope validation token_response = await handle_token_response_scopes(response) @@ -530,7 +533,7 @@ class OAuthClientProvider(httpx2.Auth): async def _handle_refresh_response(self, response: httpx2.Response) -> bool: """Handle token refresh response. Returns True if successful.""" if response.status_code != 200: - logger.warning(f"Token refresh failed: {response.status_code}") + logger.warning(f"Token refresh failed: {response.status_code}{redirect_note(response)}") self.context.clear_tokens() return False @@ -598,8 +601,8 @@ class OAuthClientProvider(httpx2.Auth): the 2025-03-26 well-known URL is built from (RFC 8414 §3.3).""" return self.context.auth_server_url or _origin_issuer(self.context.server_url) - async def async_auth_flow(self, request: httpx2.Request) -> AsyncGenerator[httpx2.Request, httpx2.Response]: - """httpx2 auth flow integration.""" + async def _auth_flow(self, request: httpx2.Request) -> AsyncGenerator[httpx2.Request, httpx2.Response]: + """The OAuth flow proper; `async_auth_flow` drives it (see `RedirectAwareAuth`).""" async with self.context.lock: if not self._initialized: await self._initialize() diff --git a/src/mcp/client/auth/utils.py b/src/mcp/client/auth/utils.py index 30d420a5..0a2ba80a 100644 --- a/src/mcp/client/auth/utils.py +++ b/src/mcp/client/auth/utils.py @@ -8,6 +8,7 @@ from pydantic import AnyUrl, ValidationError from pydantic_core import from_json from mcp.client.auth import OAuthFlowError, OAuthRegistrationError, OAuthTokenError +from mcp.shared._httpx_utils import redirect_note from mcp.shared.auth import ( OAuthClientInformationFull, OAuthClientMetadata, @@ -230,9 +231,9 @@ async def handle_auth_metadata_response(response: Response) -> tuple[bool, OAuth return True, asm except ValidationError: # pragma: no cover return True, None - elif response.status_code < 400 or response.status_code >= 500: - return False, None # Non-4XX error, stop trying - return True, None + elif 300 <= response.status_code < 500: + return True, None # Not served at this URL (redirects are not followed) - try the next candidate + return False, None # Server error or unexpected status, stop trying def validate_authorization_response_iss(iss: str | None, oauth_metadata: OAuthMetadata | None) -> None: @@ -297,7 +298,9 @@ async def handle_registration_response(response: Response) -> OAuthClientInforma """Handle registration response.""" if response.status_code not in (200, 201): await response.aread() - raise OAuthRegistrationError(f"Registration failed: {response.status_code} {response.text}") + raise OAuthRegistrationError( + f"Registration failed: {response.status_code}{redirect_note(response)} {response.text}" + ) try: content = await response.aread() diff --git a/src/mcp/client/sse.py b/src/mcp/client/sse.py index 31d0f353..f72ff273 100644 --- a/src/mcp/client/sse.py +++ b/src/mcp/client/sse.py @@ -12,7 +12,12 @@ from httpx2 import SSEError from mcp.shared._compat import resync_tracer from mcp.shared._context_streams import create_context_streams -from mcp.shared._httpx_utils import McpHttpClientFactory, create_mcp_http_client +from mcp.shared._httpx_utils import ( + McpHttpClientFactory, + create_mcp_http_client, + request_within_origin, + sse_within_origin, +) from mcp.shared.message import SessionMessage logger = logging.getLogger(__name__) @@ -47,7 +52,14 @@ async def sse_client( headers: Optional headers to include in requests. timeout: HTTP timeout for regular operations (in seconds). sse_read_timeout: Timeout for SSE read operations (in seconds). - httpx_client_factory: Factory function for creating the httpx2 client. + httpx_client_factory: Factory function for creating the httpx2 client. Whichever client it + returns, MCP requests follow a redirect only when it stays on the endpoint's origin + (same scheme, host and port, or http to https on the same host with default ports) and + keeps the request method (any status for the SSE GET, 307/308 for a message POST); any + other redirect is not followed, so connecting fails with + `httpx2.HTTPStatusError` for the redirect response. The client's `follow_redirects` + setting is not consulted; the SDK's OAuth providers apply the same rule to the requests + they make. auth: Optional httpx2 authentication handler. on_session_created: Optional callback invoked with the session ID when received. """ @@ -55,7 +67,7 @@ async def sse_client( async with httpx_client_factory( headers=headers, auth=auth, timeout=httpx2.Timeout(timeout, read=sse_read_timeout) ) as client: - async with client.sse(url) as event_source: + async with sse_within_origin(client, url) as event_source: event_source.response.raise_for_status() logger.debug("SSE connection established") @@ -121,13 +133,11 @@ async def sse_client( async def _send_message(session_message: SessionMessage) -> None: logger.debug(f"Sending client message: {session_message}") - response = await client.post( + response = await request_within_origin( + client, + "POST", endpoint_url, - json=session_message.message.model_dump( - by_alias=True, - mode="json", - exclude_unset=True, - ), + json=session_message.message.model_dump(by_alias=True, mode="json", exclude_unset=True), ) response.raise_for_status() logger.debug(f"Client message sent successfully: {response.status_code}") diff --git a/src/mcp/client/streamable_http.py b/src/mcp/client/streamable_http.py index 226b0fec..82de50fd 100644 --- a/src/mcp/client/streamable_http.py +++ b/src/mcp/client/streamable_http.py @@ -33,7 +33,13 @@ from pydantic import ValidationError from mcp.client._transport import TransportStreams from mcp.shared._compat import resync_tracer from mcp.shared._context_streams import ContextReceiveStream, ContextSendStream, create_context_streams -from mcp.shared._httpx_utils import create_mcp_http_client +from mcp.shared._httpx_utils import ( + create_mcp_http_client, + redirect_location, + request_within_origin, + sse_within_origin, + stream_within_origin, +) from mcp.shared.inbound import MCP_PROTOCOL_VERSION_HEADER from mcp.shared.jsonrpc_dispatcher import cancelled_request_id_from_params from mcp.shared.message import ClientMessageMetadata, SessionMessage @@ -62,6 +68,21 @@ class ResumptionError(StreamableHTTPError): """Raised when resumption request is invalid.""" +def _unfollowed_redirect(response: httpx2.Response) -> str | None: + """Describe a redirect `stream_within_origin` left unfollowed, or None if `response` is not one.""" + location = redirect_location(response) + if location is None: + return None + if response.request.url.scheme == "https" and location.scheme == "http": + return ( + f"Redirect to {location} not followed: it would downgrade this HTTPS endpoint to plain HTTP.\n" + "The server is likely behind a TLS-terminating proxy whose forwarded headers it does not trust,\n" + f"often combined with a trailing-slash difference. Try {location.copy_with(scheme='https')} instead, " + "or fix the proxy settings." + ) + return f"Redirect to {location} not followed; use that URL as the endpoint if it is the intended server" + + @dataclass class RequestContext: """Context for a request operation.""" @@ -210,7 +231,11 @@ class StreamableHTTPTransport: if last_event_id: headers[LAST_EVENT_ID] = last_event_id - async with client.sse(self.url, headers=headers) as event_source: + async with sse_within_origin(client, self.url, headers=headers) as event_source: + if (redirect := _unfollowed_redirect(event_source.response)) is not None: + # The same GET would be redirected again, so retrying cannot help. + logger.warning(f"GET stream not opened: {redirect}") + return event_source.response.raise_for_status() logger.debug("GET SSE connection established") @@ -253,7 +278,14 @@ class StreamableHTTPTransport: if isinstance(ctx.session_message.message, JSONRPCRequest): # pragma: no branch original_request_id = ctx.session_message.message.id - async with ctx.client.sse(self.url, headers=headers) as event_source: + async with sse_within_origin(ctx.client, self.url, headers=headers) as event_source: + if (redirect := _unfollowed_redirect(event_source.response)) is not None: + logger.warning(redirect) + assert original_request_id is not None + await self._resolve_abandoned_request( + ctx.read_stream_writer, original_request_id, redirect, code=INVALID_REQUEST + ) + return event_source.response.raise_for_status() logger.debug("Resumption GET SSE connection established") @@ -320,7 +352,8 @@ class StreamableHTTPTransport: if ctx.metadata is not None and ctx.metadata.headers is not None: headers.update(ctx.metadata.headers) - async with ctx.client.stream( + async with stream_within_origin( + ctx.client, "POST", self.url, json=message.model_dump(by_alias=True, mode="json", exclude_unset=True), @@ -339,6 +372,14 @@ class StreamableHTTPTransport: ) return + if (redirect := _unfollowed_redirect(response)) is not None: + logger.warning(redirect) + if isinstance(message, JSONRPCRequest): + await self._resolve_abandoned_request( + ctx.read_stream_writer, message.id, redirect, code=INVALID_REQUEST + ) + return + if response.status_code >= 400: if isinstance(message, JSONRPCRequest): # A spec-correct server may return the JSON-RPC error in the @@ -501,7 +542,7 @@ class StreamableHTTPTransport: headers[LAST_EVENT_ID] = last_event_id try: - async with ctx.client.sse(self.url, headers=headers) as event_source: + async with sse_within_origin(ctx.client, self.url, headers=headers) as event_source: event_source.response.raise_for_status() logger.info("Reconnected to SSE stream") @@ -626,7 +667,7 @@ class StreamableHTTPTransport: try: headers = self._prepare_headers() - response = await client.delete(self.url, headers=headers) + response = await request_within_origin(client, "DELETE", self.url, headers=headers) if response.status_code == 405: logger.debug("Server does not allow session termination") @@ -650,6 +691,13 @@ async def streamable_http_client( http_client: Optional pre-configured httpx2.AsyncClient. If None, a default client with recommended MCP timeouts will be created. To configure headers, authentication, or other HTTP settings, create an httpx2.AsyncClient and pass it here. + Whichever client is used, MCP requests follow a redirect only when it stays on the + endpoint's origin (same scheme, host and port, or http to https on the same host with + default ports) and keeps the request method (307/308 for a POST; any status for the GET + stream); any other redirect is not followed and the message it answered fails with an + error naming the location. The + client's `follow_redirects` setting is not consulted; the SDK's OAuth providers apply the + same rule to the requests they make. terminate_on_close: If True, send a DELETE request to terminate the session when the context exits. Yields: diff --git a/src/mcp/shared/_httpx_utils.py b/src/mcp/shared/_httpx_utils.py index 6bb63888..e1639e45 100644 --- a/src/mcp/shared/_httpx_utils.py +++ b/src/mcp/shared/_httpx_utils.py @@ -1,5 +1,8 @@ -"""Utilities for creating standardized httpx2 AsyncClient instances.""" +"""Utilities for creating and using httpx2 AsyncClient instances in the MCP transports.""" +from abc import ABC, abstractmethod +from collections.abc import AsyncGenerator +from contextlib import asynccontextmanager from typing import Any, Protocol import httpx2 @@ -10,6 +13,12 @@ __all__ = ["create_mcp_http_client", "MCP_DEFAULT_TIMEOUT", "MCP_DEFAULT_SSE_REA MCP_DEFAULT_TIMEOUT = 30.0 # General operations (seconds) MCP_DEFAULT_SSE_READ_TIMEOUT = 300.0 # SSE streams - 5 minutes (seconds) +# The headers httpx2.AsyncClient.sse() adds to an event-stream request. +_SSE_HEADERS = {"Accept": "text/event-stream", "Cache-Control": "no-store"} + +# How many redirects one auth-flow request may follow within its origin (see RedirectAwareAuth). +_AUTH_REDIRECT_LIMIT = 5 + class McpHttpClientFactory(Protocol): # pragma: no branch def __call__( # pragma: no branch @@ -25,9 +34,12 @@ def create_mcp_http_client( timeout: httpx2.Timeout | None = None, auth: httpx2.Auth | None = None, ) -> httpx2.AsyncClient: - """Create a standardized httpx2 AsyncClient with MCP defaults. + """Create an httpx2 AsyncClient with the MCP transports' default timeouts. - Always enables follow_redirects and applies an SSE-friendly default timeout. + The client uses a 30-second timeout for connect/write/pool and a 300-second + read timeout, because a server may hold a response stream open. Redirect + following is left at the httpx2 default (off): the MCP transports follow + redirects within the endpoint's origin themselves, see `stream_within_origin`. Args: headers: Optional headers to include with all requests. @@ -36,60 +48,169 @@ def create_mcp_http_client( auth: Optional authentication handler. Returns: - Configured httpx2.AsyncClient instance with MCP defaults. + Configured httpx2.AsyncClient instance. Note: The returned AsyncClient must be used as a context manager to ensure proper cleanup of connections. - - Example: - Basic usage with MCP defaults: - - ```python - async with create_mcp_http_client() as client: - response = await client.get("https://api.example.com") - ``` - - With custom headers: - - ```python - headers = {"Authorization": "Bearer token"} - async with create_mcp_http_client(headers) as client: - response = await client.get("/endpoint") - ``` - - With both custom headers and timeout: - - ```python - timeout = httpx2.Timeout(60.0, read=300.0) - async with create_mcp_http_client(headers, timeout) as client: - response = await client.get("/long-request") - ``` - - With authentication: - - ```python - from httpx2 import BasicAuth - auth = BasicAuth(username="user", password="pass") - async with create_mcp_http_client(headers, timeout, auth) as client: - response = await client.get("/protected-endpoint") - ``` """ - # Set MCP defaults - kwargs: dict[str, Any] = {"follow_redirects": True} - - # Handle timeout if timeout is None: - kwargs["timeout"] = httpx2.Timeout(MCP_DEFAULT_TIMEOUT, read=MCP_DEFAULT_SSE_READ_TIMEOUT) - else: - kwargs["timeout"] = timeout - - # Handle headers + timeout = httpx2.Timeout(MCP_DEFAULT_TIMEOUT, read=MCP_DEFAULT_SSE_READ_TIMEOUT) + kwargs: dict[str, Any] = {"timeout": timeout} if headers is not None: kwargs["headers"] = headers - - # Handle authentication if auth is not None: # pragma: no cover kwargs["auth"] = auth - return httpx2.AsyncClient(**kwargs) + + +def _within_origin(url: httpx2.URL, location: httpx2.URL) -> bool: + """Whether `location` is on `url`'s origin, or is its https upgrade on the default ports. + + httpx2 normalises a scheme's default port to None and lower-cases hosts, so + plain tuple comparison is exact. The upgrade rule is the one httpx2 itself + uses to decide a redirect has not left the origin (`_is_https_redirect`). + """ + if (url.scheme, url.host, url.port) == (location.scheme, location.host, location.port): + return True + return ( + url.host == location.host + and url.scheme == "http" + and url.port is None + and location.scheme == "https" + and location.port is None + ) + + +def next_request_within_origin(response: httpx2.Response) -> httpx2.Request | None: + """The request that follows `response`'s redirect, if it is one the MCP transports follow. + + That is when httpx2 built a next request for it (a redirect status with a + Location), the next request keeps the method (307/308, or any redirect of a + GET: httpx2 turns a POST into a body-less GET for 301/302/303, which would + drop the message), its URL stays within the origin of the request just sent + (same scheme, host and port, or http to https on the same host with default + ports), and the Location carries no userinfo (which httpx2 would otherwise + send as Basic auth). None for anything else, including a non-redirect. + """ + next_request = response.next_request + if next_request is None: + return None + sent = response.request + if ( + next_request.method != sent.method + or next_request.url.userinfo + or not _within_origin(sent.url, next_request.url) + ): + return None + return next_request + + +@asynccontextmanager +async def stream_within_origin( + client: httpx2.AsyncClient, method: str, url: httpx2.URL | str, **kwargs: Any +) -> AsyncGenerator[httpx2.Response]: + """`client.stream(...)`, following redirects only while they stay within the request's origin. + + An MCP transport talks to one configured endpoint, and everything on a request + (headers, auth, body) was configured for that endpoint. A redirect that + `next_request_within_origin` accepts, such as a 307/308 trailing-slash + normalisation, is followed, at most `client.max_redirects` times. Any other + redirect (or one past that budget) is not followed: the redirect response + itself is yielded, the way httpx2 hands one back when `follow_redirects` is + off, and the caller treats it as the non-success it is. The client's own + `follow_redirects` setting is not consulted. Requests an `httpx2.Auth` flow + makes during the call are sent without following either; the SDK's OAuth + providers apply the same rule to their own requests. + """ + request = client.build_request(method, url, **kwargs) + followed = 0 + while True: + response = await client.send(request, stream=True, follow_redirects=False) + next_request = next_request_within_origin(response) + if next_request is None or followed == client.max_redirects: + break + try: + # Drain the redirect body so the connection returns to the pool, as httpx2 does when it follows. + await response.aread() + finally: + await response.aclose() + request = next_request + followed += 1 + try: + yield response + finally: + await response.aclose() + + +async def request_within_origin( + client: httpx2.AsyncClient, method: str, url: httpx2.URL | str, **kwargs: Any +) -> httpx2.Response: + """`client.request(...)` with the redirect handling of `stream_within_origin`.""" + async with stream_within_origin(client, method, url, **kwargs) as response: + await response.aread() + return response + + +@asynccontextmanager +async def sse_within_origin( + client: httpx2.AsyncClient, url: httpx2.URL | str, *, headers: dict[str, str] | None = None +) -> AsyncGenerator[httpx2.EventSource]: + """`client.sse(url)` with the redirect handling of `stream_within_origin`.""" + merged = httpx2.Headers(_SSE_HEADERS) + merged.update(headers or {}) + async with stream_within_origin(client, "GET", url, headers=merged) as response: + yield httpx2.EventSource(response) + + +def redirect_location(response: httpx2.Response) -> httpx2.URL | None: + """Where `response` redirects to, for use in a message: without userinfo, query or fragment, + which can carry state that does not belong in an error or a log line. None if not a redirect.""" + if response.next_request is None: + return None + return response.next_request.url.copy_with(userinfo=b"", query=None, fragment=None) + + +def redirect_note(response: httpx2.Response) -> str: + """A suffix naming the location of a redirect response that was not followed, else empty.""" + location = redirect_location(response) + if location is None: + return "" + return f" (redirected to {location}; not followed)" + + +class RedirectAwareAuth(ABC, httpx2.Auth): + """An `httpx2.Auth` whose own requests follow redirects the way MCP transport requests do. + + The transports send every request with redirect following off and follow a + redirect themselves only within the endpoint's origin (`stream_within_origin`). + httpx2 applies that per-request setting to the requests an auth flow makes + too (metadata discovery, registration, token), so on their own those would + follow nothing. Subclasses write their flow as `_auth_flow`; this class + drives it and, for each request the flow makes other than the one being + authenticated, follows a redirect that `next_request_within_origin` accepts, + up to `_AUTH_REDIRECT_LIMIT` times. Any other redirect response is handed + to the flow as it is. + """ + + @abstractmethod + def _auth_flow(self, request: httpx2.Request) -> AsyncGenerator[httpx2.Request, httpx2.Response]: + """The subclass's flow, written as `httpx2.Auth.async_auth_flow` otherwise would be.""" + + async def async_auth_flow(self, request: httpx2.Request) -> AsyncGenerator[httpx2.Request, httpx2.Response]: + flow = self._auth_flow(request) + try: + outgoing = await flow.__anext__() + while True: + response = yield outgoing + if outgoing is not request: + for _ in range(_AUTH_REDIRECT_LIMIT): + follow = next_request_within_origin(response) + if follow is None: + break + response = yield follow + outgoing = await flow.asend(response) + except StopAsyncIteration: + return + finally: + await flow.aclose() diff --git a/tests/client/test_auth.py b/tests/client/test_auth.py index eb168e6e..18a15667 100644 --- a/tests/client/test_auth.py +++ b/tests/client/test_auth.py @@ -3,6 +3,7 @@ import base64 import json import time +from collections.abc import AsyncGenerator from unittest import mock from urllib.parse import parse_qs, quote, unquote, urlparse @@ -24,6 +25,7 @@ from mcp.client.auth.utils import ( extract_resource_metadata_from_www_auth, extract_scope_from_www_auth, get_client_metadata_scopes, + handle_auth_metadata_response, handle_registration_response, is_valid_client_metadata_url, should_use_client_metadata_url, @@ -824,6 +826,97 @@ class TestProtectedResourceMetadata: assert "resource=" in content +async def _start_discovery( + provider: OAuthClientProvider, +) -> tuple[AsyncGenerator[httpx2.Request, httpx2.Response], httpx2.Request]: + """Drive `provider`'s auth flow to the point where it has sent the MCP request, seen a 401 and + issued its first protected-resource-metadata request; returns (flow, that request).""" + provider.context.current_tokens = None + provider.context.token_expiry_time = None + provider._initialized = True + mcp_request = httpx2.Request("POST", "https://api.example.com/v1/mcp") + flow = provider.async_auth_flow(mcp_request) + sent = await flow.__anext__() + assert sent is mcp_request + # No resource_metadata hint, so discovery tries the path-based well-known URL, then the root one. + unauthorized = httpx2.Response(401, request=mcp_request) + prm_request = await flow.asend(unauthorized) + assert (prm_request.method, str(prm_request.url)) == ( + "GET", + "https://api.example.com/.well-known/oauth-protected-resource/v1/mcp", + ) + return flow, prm_request + + +async def _redirect(request: httpx2.Request, status: int, location: str) -> httpx2.Response: + """A redirect answer to `request`, as httpx2 hands it back when it does not follow it.""" + transport = httpx2.MockTransport(lambda r: httpx2.Response(status, headers={"location": location})) + async with httpx2.AsyncClient(transport=transport) as client: + return await client.send(request) + + +@pytest.mark.anyio +async def test_auth_flow_follows_a_same_origin_redirect_of_its_own_request(oauth_provider: OAuthClientProvider): + """SDK-defined: a request the OAuth flow makes (here protected-resource metadata discovery) + follows a redirect that stays within its origin and keeps its method, like an MCP request.""" + flow, prm_request = await _start_discovery(oauth_provider) + + follow_up = await flow.asend(await _redirect(prm_request, 307, "/.well-known/oauth-protected-resource/v1/mcp/")) + + assert (follow_up.method, str(follow_up.url)) == ( + "GET", + "https://api.example.com/.well-known/oauth-protected-resource/v1/mcp/", + ) + await flow.aclose() + + +@pytest.mark.anyio +async def test_auth_flow_does_not_follow_a_redirect_of_its_own_request_to_another_origin( + oauth_provider: OAuthClientProvider, +): + """SDK-defined: a redirect of a flow request to another origin is handed to the flow unfollowed, + which treats it as "not served here" and moves to its next discovery URL.""" + flow, prm_request = await _start_discovery(oauth_provider) + + next_request = await flow.asend(await _redirect(prm_request, 307, "https://elsewhere.example/prm")) + + assert (next_request.method, str(next_request.url)) == ( + "GET", + "https://api.example.com/.well-known/oauth-protected-resource", + ) + await flow.aclose() + + +@pytest.mark.anyio +async def test_auth_flow_stops_following_a_redirecting_request_after_a_few_hops( + oauth_provider: OAuthClientProvider, +): + """SDK-defined: a flow request that keeps redirecting within its origin is followed a bounded + number of times; the redirect after that is handed to the flow unfollowed.""" + flow, request = await _start_discovery(oauth_provider) + + hops = 0 + while str(request.url) != "https://api.example.com/.well-known/oauth-protected-resource": + request = await flow.asend( + await _redirect(request, 307, f"/.well-known/oauth-protected-resource/v1/mcp/{hops}") + ) + hops += 1 + + assert hops == 6 # five followed, the sixth handed back and taken as "try the next URL" + await flow.aclose() + + +@pytest.mark.anyio +@pytest.mark.parametrize(("status", "keep_trying"), [(404, True), (307, True), (500, False)]) +async def test_auth_metadata_response_says_whether_to_try_the_next_discovery_url( + status: int, keep_trying: bool +) -> None: + """SDK-defined: a 4xx or a 3xx (redirects are not followed on these requests) from a discovery + candidate means the metadata is not served there and the next well-known URL is tried; a 5xx + stops discovery.""" + assert await handle_auth_metadata_response(httpx2.Response(status)) == (keep_trying, None) + + @pytest.mark.parametrize( ("protocol_version", "expected"), [ @@ -977,35 +1070,33 @@ class TestRegistrationResponse: @pytest.mark.anyio async def test_handle_registration_response_reads_before_accessing_text(self): - """Test that response.aread() is called before accessing response.text.""" + """The registration error carries the response text, which for a streamed response means + reading it first (a streamed httpx2 response raises ResponseNotRead otherwise).""" + response = httpx2.Response(400, stream=httpx2.ByteStream(b"Registration failed with error")) - # Track if aread() was called - class MockResponse(httpx2.Response): - def __init__(self): - self.status_code = 400 - self._aread_called = False - self._text = "Registration failed with error" + with pytest.raises(OAuthRegistrationError) as exc_info: + await handle_registration_response(response) - async def aread(self): - self._aread_called = True - return b"test content" + assert str(exc_info.value) == snapshot("Registration failed: 400 Registration failed with error") - @property - def text(self): - if not self._aread_called: - raise RuntimeError("Response.text accessed before response.aread()") # pragma: no cover - return self._text + @pytest.mark.anyio + async def test_registration_error_names_an_unfollowed_redirect(self): + """SDK-defined: when the registration endpoint answered with a redirect that was not followed, + the error says where it pointed (without userinfo or query) instead of only the bare status.""" + request = httpx2.Request("POST", "https://as.example/register") + async with httpx2.AsyncClient( + transport=httpx2.MockTransport( + lambda r: httpx2.Response(307, headers={"location": "https://u:p@elsewhere.example/register?state=x"}) + ) + ) as client: + response = await client.send(request) - mock_response = MockResponse() + with pytest.raises(OAuthRegistrationError) as exc_info: + await handle_registration_response(response) - # This should call aread() before accessing text - with pytest.raises(Exception) as exc_info: - await handle_registration_response(mock_response) - - # Verify aread() was called - assert mock_response._aread_called - # Verify the error message includes the response text - assert "Registration failed: 400" in str(exc_info.value) + assert str(exc_info.value) == snapshot( + "Registration failed: 307 (redirected to https://elsewhere.example/register; not followed) " + ) @pytest.mark.anyio diff --git a/tests/client/test_http_unicode.py b/tests/client/test_http_unicode.py index ef9511fb..9996c192 100644 --- a/tests/client/test_http_unicode.py +++ b/tests/client/test_http_unicode.py @@ -112,11 +112,9 @@ async def unicode_session() -> AsyncIterator[ClientSession]: async with ( session_manager.run(), - # follow_redirects matches the SDK's own client factory; Starlette's Mount 307-redirects - # the bare /mcp path to /mcp/. - httpx2.AsyncClient( - transport=StreamingASGITransport(app), base_url=BASE_URL, follow_redirects=True - ) as http_client, + # Starlette's Mount 307-redirects the bare /mcp path to /mcp/; the transport follows that + # same-origin redirect itself. + httpx2.AsyncClient(transport=StreamingASGITransport(app), base_url=BASE_URL) as http_client, streamable_http_client(f"{BASE_URL}/mcp", http_client=http_client) as (read_stream, write_stream), ClientSession(read_stream, write_stream) as session, ): diff --git a/tests/client/test_streamable_http.py b/tests/client/test_streamable_http.py index d21f520d..c6e62ad9 100644 --- a/tests/client/test_streamable_http.py +++ b/tests/client/test_streamable_http.py @@ -26,18 +26,24 @@ from mcp_types import ( JSONRPCNotification, JSONRPCRequest, JSONRPCResponse, + ListToolsResult, + PaginatedRequestParams, ) from mcp_types.version import LATEST_MODERN_VERSION +from starlette.applications import Starlette +from starlette.routing import Mount from starlette.types import Receive, Scope, Send +from mcp import Client, MCPError from mcp.client.streamable_http import ( MAX_RECONNECTION_ATTEMPTS, RequestContext, StreamableHTTPTransport, streamable_http_client, ) -from mcp.server import Server +from mcp.server import Server, ServerRequestContext from mcp.server._streamable_http_modern import handle_modern_request +from mcp.server.streamable_http_manager import StreamableHTTPSessionManager from mcp.server.subscriptions import InMemorySubscriptionBus, ListenHandler, ServerEvent from mcp.shared._context_streams import ContextSendStream, create_context_streams from mcp.shared.dispatcher import CallOptions, DispatchContext @@ -748,3 +754,217 @@ async def test_resolving_an_abandoned_request_after_the_reader_closed_is_contain _abandoned_request_context(http, send), "evt-7", None, MAX_RECONNECTION_ATTEMPTS ) send.close() + + +@pytest.mark.anyio +async def test_trailing_slash_redirect_within_origin_is_followed_by_the_transport() -> None: + """SDK-defined: a redirect that stays on the endpoint's origin (here Starlette's Mount sending + /mcp to /mcp/) is followed by the transport itself, so a caller-supplied client left at + httpx2's no-follow default still connects.""" + session_manager = StreamableHTTPSessionManager(app=Server("redirect-test")) + app = Starlette(routes=[Mount("/mcp", app=session_manager.handle_request)]) + urls: list[str] = [] + + async def record(request: httpx2.Request) -> None: + urls.append(str(request.url)) + + with anyio.fail_after(5): + async with ( + session_manager.run(), + httpx2.AsyncClient(transport=StreamingASGITransport(app), event_hooks={"request": [record]}) as http, + Client(streamable_http_client("http://mcp.example/mcp", http_client=http)) as client, + ): + assert client.server_info is not None + assert client.server_info.name == "redirect-test" + + assert urls[:2] == ["http://mcp.example/mcp", "http://mcp.example/mcp/"] + + +class _RedirectPromptsListElsewhere(httpx2.AsyncBaseTransport): + """Serves `app` in process, except that a prompts/list POST is answered with a redirect to + another origin.""" + + def __init__(self, app: Starlette) -> None: + self.inner = StreamingASGITransport(app) + + async def handle_async_request(self, request: httpx2.Request) -> httpx2.Response: + await request.aread() + if request.method == "POST" and json.loads(request.content).get("method") == "prompts/list": + return httpx2.Response(307, headers={"location": "http://other.example/mcp/"}) + return await self.inner.handle_async_request(request) + + async def __aenter__(self) -> "_RedirectPromptsListElsewhere": + await self.inner.__aenter__() + return self + + async def __aexit__(self, *args: Any) -> None: + await self.inner.__aexit__(*args) + + +@pytest.mark.anyio +async def test_redirect_to_another_origin_fails_that_call_and_keeps_the_session() -> None: + """SDK-defined: a redirect pointing outside the endpoint's origin is not followed, whatever the + caller's client is configured to do: the call it answered fails with MCPError naming the + location, nothing is sent to the other origin, and the session stays usable.""" + + async def list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult: + return ListToolsResult(tools=[]) + + session_manager = StreamableHTTPSessionManager(app=Server("redirect-test", on_list_tools=list_tools)) + app = Starlette(routes=[Mount("/mcp", app=session_manager.handle_request)]) + urls: list[str] = [] + + async def record(request: httpx2.Request) -> None: + urls.append(str(request.url)) + + with anyio.fail_after(5): + async with ( + session_manager.run(), + httpx2.AsyncClient( + transport=_RedirectPromptsListElsewhere(app), event_hooks={"request": [record]}, follow_redirects=True + ) as http, + Client(streamable_http_client("http://mcp.example/mcp/", http_client=http)) as client, + ): + with pytest.raises(MCPError) as exc_info: + await client.list_prompts() + assert (await client.list_tools()).tools == [] + + assert exc_info.value.error.code == INVALID_REQUEST + assert exc_info.value.error.message == snapshot( + "Redirect to http://other.example/mcp/ not followed; use that URL as the endpoint if it is the intended server" + ) + assert [url for url in urls if "other.example" in url] == [] + + +@pytest.mark.anyio +async def test_redirected_notification_is_dropped_and_the_next_message_still_goes_out() -> None: + """SDK-defined: a notification whose POST is redirected outside the origin has no waiter to + resolve, so it is logged and dropped; the transport keeps serving the write stream.""" + urls: list[str] = [] + + def handler(request: httpx2.Request) -> httpx2.Response: + urls.append(str(request.url)) + return httpx2.Response(307, headers={"location": "http://other.example/mcp"}) + + with anyio.fail_after(5): + async with ( + httpx2.AsyncClient(transport=httpx2.MockTransport(handler)) as http, + streamable_http_client("http://test/mcp", http_client=http) as (read, write), + ): + await write.send( + SessionMessage(JSONRPCNotification(jsonrpc="2.0", method="notifications/roots/list_changed")) + ) + await write.send(SessionMessage(JSONRPCRequest(jsonrpc="2.0", id=7, method="tools/list", params={}))) + reply = await read.receive() + assert isinstance(reply, SessionMessage) + assert isinstance(reply.message, JSONRPCError) + assert reply.message.id == 7 + assert reply.message.error.code == INVALID_REQUEST + assert urls == ["http://test/mcp", "http://test/mcp"] + + +@pytest.mark.anyio +async def test_get_stream_gives_up_without_retrying_when_the_endpoint_redirects_elsewhere() -> None: + """SDK-defined: the standalone GET stream is not opened through a redirect to another origin, + and since the same GET would be redirected again the transport logs it and stops instead of + spending its reconnection attempts.""" + gets: list[str] = [] + + def handler(request: httpx2.Request) -> httpx2.Response: + gets.append(str(request.url)) + return httpx2.Response(307, headers={"location": "http://other.example/mcp"}) + + transport = StreamableHTTPTransport("http://test/mcp") + transport.session_id = "session-1" + send, receive = create_context_streams[SessionMessage | Exception](1) + with anyio.fail_after(5): + async with httpx2.AsyncClient(transport=httpx2.MockTransport(handler)) as http: + await transport.handle_get_stream(http, send) + assert gets == ["http://test/mcp"] + send.close() + receive.close() + + +@pytest.mark.anyio +async def test_resumption_redirected_elsewhere_resolves_that_request_with_an_error() -> None: + """SDK-defined: a resumption GET answered with a redirect to another origin is not followed; + the resumed request is resolved with an error naming the location rather than left waiting.""" + seen: list[tuple[str, str | None]] = [] + + def handler(request: httpx2.Request) -> httpx2.Response: + seen.append((f"{request.method} {request.url}", request.headers.get("last-event-id"))) + return httpx2.Response(307, headers={"location": "http://other.example/mcp"}) + + with anyio.fail_after(5): + async with ( + httpx2.AsyncClient(transport=httpx2.MockTransport(handler)) as http, + streamable_http_client("http://test/mcp", http_client=http) as (read, write), + ): + await write.send( + SessionMessage( + JSONRPCRequest(jsonrpc="2.0", id="resume-1", method="tools/call", params={}), + metadata=ClientMessageMetadata(resumption_token="evt-41"), + ) + ) + reply = await read.receive() + assert isinstance(reply, SessionMessage) + assert isinstance(reply.message, JSONRPCError) + assert reply.message.id == "resume-1" + assert reply.message.error.code == INVALID_REQUEST + assert reply.message.error.message == snapshot( + "Redirect to http://other.example/mcp not followed; use that URL as the endpoint if it is the intended server" + ) + assert seen == [("GET http://test/mcp", "evt-41")] + + +async def _redirected_call_error(url: str, location: str) -> str: + """Send one request through streamable_http_client to a server answering `url` with a 307 to + `location`, and return the message of the error that resolves it.""" + + def handler(request: httpx2.Request) -> httpx2.Response: + return httpx2.Response(307, headers={"location": location}) + + with anyio.fail_after(5): + async with ( + httpx2.AsyncClient(transport=httpx2.MockTransport(handler)) as http, + streamable_http_client(url, http_client=http) as (read, write), + ): + await write.send(SessionMessage(JSONRPCRequest(jsonrpc="2.0", id=1, method="tools/list", params={}))) + reply = await read.receive() + assert isinstance(reply, SessionMessage) + assert isinstance(reply.message, JSONRPCError) + return reply.message.error.message + + +@pytest.mark.anyio +async def test_https_endpoint_redirected_to_plain_http_is_explained_and_the_https_form_suggested() -> None: + """SDK-authored text: a redirect of an HTTPS endpoint to plain HTTP on the same host (the usual + sign of a TLS-terminating proxy the server does not trust) never suggests the http:// URL.""" + message = await _redirected_call_error("https://mcp.example/mcp", "http://mcp.example/mcp/") + assert message == snapshot("""\ +Redirect to http://mcp.example/mcp/ not followed: it would downgrade this HTTPS endpoint to plain HTTP. +The server is likely behind a TLS-terminating proxy whose forwarded headers it does not trust, +often combined with a trailing-slash difference. Try https://mcp.example/mcp/ instead, or fix the proxy settings.\ +""") + + +@pytest.mark.anyio +async def test_unfollowed_redirect_location_is_named_without_its_query_string() -> None: + """SDK-authored text: the location is reported without query or userinfo, which may carry state + that does not belong in an error message or a log line.""" + message = await _redirected_call_error("http://mcp.example/mcp", "https://sso.example/login?state=s3cr3t&nonce=n") + assert message == snapshot( + "Redirect to https://sso.example/login not followed; use that URL as the endpoint if it is the intended server" + ) + + +@pytest.mark.anyio +async def test_https_endpoint_redirected_to_plain_http_elsewhere_never_suggests_the_http_url() -> None: + """SDK-authored text: the downgrade explanation applies whatever host the http:// location names, + so the message never offers a plain-HTTP URL as the endpoint to configure.""" + message = await _redirected_call_error("https://mcp.example/mcp", "http://backend.lan:8000/mcp/") + assert message == snapshot("""\ +Redirect to http://backend.lan:8000/mcp/ not followed: it would downgrade this HTTPS endpoint to plain HTTP. +The server is likely behind a TLS-terminating proxy whose forwarded headers it does not trust, +often combined with a trailing-slash difference. Try https://backend.lan:8000/mcp/ instead, or fix the proxy settings.\ +""") diff --git a/tests/shared/test_httpx_utils.py b/tests/shared/test_httpx_utils.py index a94d7c92..5e4eac2f 100644 --- a/tests/shared/test_httpx_utils.py +++ b/tests/shared/test_httpx_utils.py @@ -1,16 +1,27 @@ -"""Tests for httpx2 utility functions.""" +"""Tests for the httpx2 helpers the client transports are built on.""" + +from collections.abc import AsyncGenerator, AsyncIterator +from typing import Any import httpx2 +import pytest -from mcp.shared._httpx_utils import create_mcp_http_client +from mcp.shared._httpx_utils import ( + create_mcp_http_client, + request_within_origin, + sse_within_origin, + stream_within_origin, +) + +pytestmark = pytest.mark.anyio -def test_default_settings(): - """Test that default settings are applied correctly.""" +def test_default_client_uses_mcp_timeouts_and_httpx_redirect_default(): + """The factory applies the transports' timeouts and leaves redirect following to the transports.""" client = create_mcp_http_client() - assert client.follow_redirects is True - assert client.timeout.connect == 30.0 + assert client.follow_redirects is False + assert client.timeout == httpx2.Timeout(30.0, read=300.0) def test_custom_parameters(): @@ -22,3 +33,239 @@ def test_custom_parameters(): assert client.headers["Authorization"] == "Bearer token" assert client.timeout.connect == 60.0 + + +class _Body(httpx2.AsyncByteStream): + """A response body served as a real stream, recording whether the client closed it.""" + + def __init__(self, data: bytes, closed: list[bool]) -> None: + self._data = data + self._closed = closed + + async def __aiter__(self) -> AsyncIterator[bytes]: + yield self._data + + async def aclose(self) -> None: + self._closed.append(True) + + +def _recording_client( + redirects: dict[str, tuple[int, str]], **client_kwargs: Any +) -> tuple[httpx2.AsyncClient, list[str], list[bool]]: + """A client whose server redirects each URL in `redirects` (status, Location) and answers 200 + to anything else; plus the `METHOD url` lines the server received and one entry per redirect + response body the client closed.""" + received: list[str] = [] + closed: list[bool] = [] + + def serve(request: httpx2.Request) -> httpx2.Response: + received.append(f"{request.method} {request.url}") + if str(request.url) in redirects: + status, location = redirects[str(request.url)] + return httpx2.Response(status, headers={"location": location}, stream=_Body(b"moved", closed)) + return httpx2.Response(200, text=request.content.decode() or "ok") + + return httpx2.AsyncClient(transport=httpx2.MockTransport(serve), **client_kwargs), received, closed + + +@pytest.mark.parametrize( + ("url", "location"), + [ + ("http://mcp.example/mcp", "http://mcp.example/mcp/"), + ("http://mcp.example/mcp", "/other/path"), + ("http://mcp.example:8080/mcp", "http://mcp.example:8080/v2/mcp"), + ("http://mcp.example/mcp", "http://MCP.EXAMPLE:80/mcp/"), + ("http://mcp.example/mcp", "https://mcp.example:443/mcp"), + ], +) +async def test_redirect_within_origin_is_followed_with_method_and_body(url: str, location: str): + """A redirect that stays on the request's origin (or upgrades it to https) is followed, and a + 307 keeps the method and body (SDK-defined policy; the re-send itself is httpx2's).""" + client, received, closed = _recording_client({url: (307, location)}) + + async with client, stream_within_origin(client, "POST", url, content=b"payload") as response: + await response.aread() + + assert response.status_code == 200 + assert response.text == "payload" + assert received == [f"POST {url}", f"POST {httpx2.URL(url).join(location)}"] + assert closed == [True] + + +@pytest.mark.parametrize( + "location", + [ + "http://other.example/mcp", + "http://mcp.example:8080/mcp", + "http://sub.mcp.example/mcp", + "https://mcp.example:8443/mcp", + "ftp://mcp.example/mcp", + ], +) +async def test_redirect_outside_origin_is_not_followed(location: str): + """A redirect to another origin is handed back unfollowed, the way httpx2 hands back a redirect + with following off, and the location is never requested (SDK-defined policy).""" + url = "http://mcp.example/mcp" + client, received, closed = _recording_client({url: (307, location)}) + + async with client, stream_within_origin(client, "POST", url, content=b"payload") as response: + pass + + assert response.status_code == 307 + assert response.next_request is not None + assert response.next_request.url == location + assert received == [f"POST {url}"] + assert closed == [True] + + +@pytest.mark.parametrize("status", [301, 302, 303]) +async def test_method_changing_redirect_of_a_post_is_not_followed(status: int): + """httpx2 turns a POST into a body-less GET for 301/302/303, which would drop the message, so a + same-origin redirect with one of those codes is handed back unfollowed (SDK-defined).""" + url = "http://mcp.example/mcp" + client, received, _ = _recording_client({url: (status, "/mcp/")}) + + async with client, stream_within_origin(client, "POST", url, content=b"payload") as response: + pass + + assert response.status_code == status + assert received == [f"POST {url}"] + + +@pytest.mark.parametrize("status", [301, 302, 303, 307, 308]) +async def test_same_origin_redirect_of_a_get_is_followed_for_every_redirect_status(status: int): + """A GET keeps its method under every redirect status, so the SSE GET follows all of them + within the origin (SDK-defined policy over httpx2's method rules).""" + url = "http://mcp.example/sse" + client, received, _ = _recording_client({url: (status, "/sse/")}) + + async with client, stream_within_origin(client, "GET", url) as response: + await response.aread() + + assert response.status_code == 200 + assert received == [f"GET {url}", "GET http://mcp.example/sse/"] + + +async def test_https_to_http_on_same_host_is_outside_origin(): + """Only the upgrade direction counts as staying on the origin; a downgrade is not followed.""" + url = "https://mcp.example/mcp" + client, received, _ = _recording_client({url: (302, "http://mcp.example/mcp")}) + + async with client, stream_within_origin(client, "GET", url) as response: + pass + + assert response.status_code == 302 + assert received == [f"GET {url}"] + + +async def test_client_configured_to_follow_redirects_is_still_scoped_to_origin(): + """The client's own follow_redirects=True does not widen the policy: the transport helper + decides per request (SDK-defined).""" + url = "http://mcp.example/mcp" + client, received, _ = _recording_client({url: (307, "http://other.example/mcp")}, follow_redirects=True) + + async with client, stream_within_origin(client, "POST", url) as response: + pass + + assert response.status_code == 307 + assert received == [f"POST {url}"] + + +async def test_redirect_past_the_client_max_redirects_budget_is_handed_back_unfollowed(): + """Same-origin hops are bounded by the client's max_redirects; the redirect after that is not + followed but handed back like any other, so a loop fails the one call rather than raising + (SDK-defined; max_redirects=0 therefore means "follow none").""" + url = "http://mcp.example/a" + client, received, closed = _recording_client( + { + "http://mcp.example/a": (307, "/b"), + "http://mcp.example/b": (307, "/c"), + "http://mcp.example/c": (307, "/d"), + }, + max_redirects=2, + ) + + async with client: + response = await request_within_origin(client, "GET", url) + + assert response.status_code == 307 + assert response.next_request is not None + assert response.next_request.url == "http://mcp.example/d" + assert received == ["GET http://mcp.example/a", "GET http://mcp.example/b", "GET http://mcp.example/c"] + assert closed == [True, True, True] + + +async def test_redirect_location_with_userinfo_is_not_followed(): + """A Location carrying user:password is handed back unfollowed even within the origin, since + httpx2 would otherwise send that userinfo as Basic auth (SDK-defined).""" + url = "http://mcp.example/mcp" + client, received, _ = _recording_client({url: (307, "http://user:secret@mcp.example/mcp/")}) + + async with client, stream_within_origin(client, "POST", url) as response: + pass + + assert response.status_code == 307 + assert received == [f"POST {url}"] + + +async def test_request_within_origin_returns_a_read_response(): + """The non-streaming form hands back a response whose body is already read.""" + url = "http://mcp.example/mcp" + client, received, _ = _recording_client({url: (307, "/mcp/")}) + + async with client: + response = await request_within_origin(client, "DELETE", url) + + assert response.status_code == 200 + assert response.text == "ok" + assert received == [f"DELETE {url}", "DELETE http://mcp.example/mcp/"] + + +async def test_sse_within_origin_sends_event_stream_headers_and_caller_headers(): + """The SSE form asks for an event stream exactly as client.sse() does, merged case-insensitively + with the caller's headers, and yields an EventSource over the final response.""" + seen: list[httpx2.Headers] = [] + + def serve(request: httpx2.Request) -> httpx2.Response: + seen.append(request.headers) + return httpx2.Response(200, headers={"content-type": "text/event-stream"}, text="data: hello\n\n") + + client = httpx2.AsyncClient(transport=httpx2.MockTransport(serve)) + async with client: + async with sse_within_origin(client, "http://mcp.example/sse") as source: + events = [event.data async for event in source] + async with sse_within_origin(client, "http://mcp.example/sse", headers={"accept": "x/y", "k": "v"}): + pass + + assert events == ["hello"] + assert seen[0]["accept"] == "text/event-stream" + assert seen[0]["cache-control"] == "no-store" + assert seen[1].get_list("accept") == ["x/y"] + assert seen[1]["cache-control"] == "no-store" + assert seen[1]["k"] == "v" + + +async def test_auth_flow_requests_are_not_redirected(): + """Requests an httpx2 Auth flow issues while a transport request is in flight (a token refresh, + say) inherit the per-request no-follow setting, so a redirect on them is handed back to the + auth flow rather than followed (httpx2 behaviour the transports rely on).""" + received: list[str] = [] + + class TokenThenRequest(httpx2.Auth): + async def async_auth_flow(self, request: httpx2.Request) -> AsyncGenerator[httpx2.Request, httpx2.Response]: + token_response = yield httpx2.Request("POST", "http://mcp.example/token", content=b"grant") + request.headers["x-token-status"] = str(token_response.status_code) + yield request + + def serve(request: httpx2.Request) -> httpx2.Response: + received.append(f"{request.method} {request.url}") + if request.url.path == "/token": + return httpx2.Response(307, headers={"location": "http://other.example/token"}) + return httpx2.Response(200, text=request.headers["x-token-status"]) + + client = httpx2.AsyncClient(transport=httpx2.MockTransport(serve), auth=TokenThenRequest(), follow_redirects=True) + async with client: + response = await request_within_origin(client, "POST", "http://mcp.example/mcp") + + assert response.text == "307" + assert received == ["POST http://mcp.example/token", "POST http://mcp.example/mcp"] diff --git a/tests/shared/test_sse.py b/tests/shared/test_sse.py index c27dd69d..77d1b28a 100644 --- a/tests/shared/test_sse.py +++ b/tests/shared/test_sse.py @@ -3,14 +3,13 @@ import json from collections.abc import AsyncGenerator from typing import Any -from unittest.mock import AsyncMock, MagicMock, Mock +from unittest.mock import Mock from urllib.parse import urlparse import anyio import httpx2 import mcp_types as types import pytest -from httpx2 import ServerSentEvent from inline_snapshot import snapshot from mcp_types import ( CallToolRequestParams, @@ -41,6 +40,7 @@ from mcp.server.sse import SseServerTransport from mcp.server.transport_security import TransportSecuritySettings from mcp.shared._httpx_utils import McpHttpClientFactory from mcp.shared.exceptions import MCPError +from mcp.shared.message import SessionMessage from tests.interaction.transports import StreamingASGITransport SERVER_NAME = "test_server_for_SSE" @@ -58,15 +58,13 @@ def in_process_client_factory(app: Starlette) -> McpHttpClientFactory: auth: httpx2.Auth | None = None, ) -> httpx2.AsyncClient: # The SSE GET runs until it observes a disconnect, so the bridge must let the - # application drain on close rather than cancelling it. follow_redirects matches - # create_mcp_http_client, the factory this one stands in for. + # application drain on close rather than cancelling it. return httpx2.AsyncClient( transport=StreamingASGITransport(app, cancel_on_close=False), base_url=BASE_URL, headers=headers, timeout=timeout, auth=auth, - follow_redirects=True, ) return factory @@ -400,10 +398,10 @@ async def test_sse_client_handles_empty_keepalive_pings() -> None: send an SSE event consisting of an event ID and an empty data field in order to prime the client to reconnect." - This test mocks the SSE event stream to include empty "message" events and - verifies the client skips them without crashing. + The event stream served here carries an endpoint event, an empty "message" + event (the case under test), then a real response; the client must skip the + empty one and deliver the response. """ - # Build a proper JSON-RPC response using types (not hardcoded strings) init_result = InitializeResult( protocol_version="2024-11-05", capabilities=ServerCapabilities(), @@ -415,41 +413,86 @@ async def test_sse_client_handles_empty_keepalive_pings() -> None: result=init_result.model_dump(by_alias=True, exclude_none=True), ) response_json = response.model_dump_json(by_alias=True, exclude_none=True) + event_stream = ( + "event: endpoint\ndata: /messages/?session_id=abc123\n\n" + "event: message\ndata: \n\n" + f"event: message\ndata: {response_json}\n\n" + ) - # Mock SSE events using httpx2's ServerSentEvent: an endpoint event, an - # empty keep-alive ping (the case under test), then a real response. - mock_event_source = MagicMock() - mock_event_source.__aiter__.return_value = [ - ServerSentEvent(event="endpoint", data="/messages/?session_id=abc123"), - ServerSentEvent(event="message", data=""), - ServerSentEvent(event="message", data=response_json), - ] - mock_event_source.response.raise_for_status = MagicMock() + def serve(request: httpx2.Request) -> httpx2.Response: + assert request.url.path == "/sse" + return httpx2.Response(200, headers={"content-type": "text/event-stream"}, text=event_stream) - mock_sse = MagicMock() - mock_sse.__aenter__ = AsyncMock(return_value=mock_event_source) - mock_sse.__aexit__ = AsyncMock(return_value=None) - - mock_client = MagicMock() - mock_client.__aenter__ = AsyncMock(return_value=mock_client) - mock_client.__aexit__ = AsyncMock(return_value=None) - mock_client.sse = MagicMock(return_value=mock_sse) - mock_client.post = AsyncMock(return_value=MagicMock(status_code=200, raise_for_status=MagicMock())) - - def mock_factory( + def factory( headers: dict[str, str] | None = None, timeout: httpx2.Timeout | None = None, auth: httpx2.Auth | None = None, ) -> httpx2.AsyncClient: - return mock_client + return httpx2.AsyncClient(transport=httpx2.MockTransport(serve)) - async with sse_client("http://test/sse", httpx_client_factory=mock_factory) as (read_stream, _): - # Read the message - should skip the empty one and get the real response - msg = await read_stream.receive() - # If we get here without error, the empty message was skipped successfully - assert not isinstance(msg, Exception) - assert isinstance(msg.message, types.JSONRPCResponse) - assert msg.message.id == 1 + with anyio.fail_after(5): + async with sse_client("http://test/sse", httpx_client_factory=factory) as (read_stream, _): + msg = await read_stream.receive() + + assert isinstance(msg, SessionMessage) + assert isinstance(msg.message, types.JSONRPCResponse) + assert msg.message.id == 1 + + +@pytest.mark.anyio +async def test_sse_client_follows_redirect_within_origin_on_connect() -> None: + """SDK-defined: a redirect of the SSE GET that stays on the endpoint's origin is followed by + the transport itself, with a client left at httpx2's no-follow default.""" + received: list[str] = [] + + def serve(request: httpx2.Request) -> httpx2.Response: + received.append(str(request.url)) + if request.url.path == "/sse": + return httpx2.Response(307, headers={"location": "/sse/"}) + assert request.url.path == "/sse/" + return httpx2.Response( + 200, headers={"content-type": "text/event-stream"}, text="event: endpoint\ndata: /messages/\n\n" + ) + + def factory( + headers: dict[str, str] | None = None, + timeout: httpx2.Timeout | None = None, + auth: httpx2.Auth | None = None, + ) -> httpx2.AsyncClient: + return httpx2.AsyncClient(transport=httpx2.MockTransport(serve)) + + with anyio.fail_after(5): + async with sse_client("http://test/sse", httpx_client_factory=factory): + pass + + assert received == ["http://test/sse", "http://test/sse/"] + + +@pytest.mark.anyio +async def test_sse_client_does_not_follow_redirect_to_another_origin_on_connect() -> None: + """SDK-defined: a redirect of the SSE GET to another origin is not followed, even with a client + configured to follow redirects: connecting fails with HTTPStatusError for the redirect response + and that origin is never contacted.""" + received: list[str] = [] + + def serve(request: httpx2.Request) -> httpx2.Response: + received.append(str(request.url)) + return httpx2.Response(307, headers={"location": "http://other.example/sse"}) + + def factory( + headers: dict[str, str] | None = None, + timeout: httpx2.Timeout | None = None, + auth: httpx2.Auth | None = None, + ) -> httpx2.AsyncClient: + return httpx2.AsyncClient(transport=httpx2.MockTransport(serve), follow_redirects=True) + + with anyio.fail_after(5): + with pytest.raises(httpx2.HTTPStatusError) as exc_info: + async with sse_client("http://test/sse", httpx_client_factory=factory): + pytest.fail("should not connect") # pragma: no cover + + assert exc_info.value.response.status_code == 307 + assert received == ["http://test/sse"] @pytest.mark.anyio diff --git a/tests/shared/test_streamable_http.py b/tests/shared/test_streamable_http.py index 2e678a5c..93700bf0 100644 --- a/tests/shared/test_streamable_http.py +++ b/tests/shared/test_streamable_http.py @@ -358,10 +358,11 @@ async def running_app( def make_client(app: Starlette, headers: dict[str, str] | None = None) -> httpx2.AsyncClient: - """An httpx2 client served in process by `app`, with create_mcp_http_client's redirect default. + """An httpx2 client served in process by `app`. - (Starlette's Mount 307-redirects the bare /mcp path to /mcp/, which the SDK's own client - factory follows.) + Starlette's Mount 307-redirects the bare /mcp path to /mcp/. The MCP transport follows that + same-origin redirect itself; `follow_redirects=True` is here for the tests in this file that + POST to /mcp with this client directly. """ return httpx2.AsyncClient( transport=StreamingASGITransport(app), base_url=BASE_URL, headers=headers, follow_redirects=True @@ -1089,7 +1090,9 @@ async def test_streamable_http_client_get_stream(basic_app: Starlette) -> None: assert resource_update_found, "ResourceUpdatedNotification not received via GET stream" -def create_session_id_capturing_client(app: Starlette) -> tuple[httpx2.AsyncClient, list[str]]: +def create_session_id_capturing_client( + app: Starlette, transport: httpx2.AsyncBaseTransport | None = None +) -> tuple[httpx2.AsyncClient, list[str]]: """Create an in-process httpx2 client that captures the session ID from responses.""" captured_ids: list[str] = [] @@ -1099,9 +1102,8 @@ def create_session_id_capturing_client(app: Starlette) -> tuple[httpx2.AsyncClie captured_ids.append(session_id) client = httpx2.AsyncClient( - transport=StreamingASGITransport(app), + transport=transport or StreamingASGITransport(app), base_url=BASE_URL, - follow_redirects=True, event_hooks={"response": [capture_session_id]}, ) return client, captured_ids @@ -1145,36 +1147,34 @@ async def test_streamable_http_client_session_termination(basic_app: Starlette) @pytest.mark.anyio -async def test_streamable_http_client_session_termination_204( - basic_app: Starlette, monkeypatch: pytest.MonkeyPatch -) -> None: +async def test_streamable_http_client_session_termination_204(basic_app: Starlette) -> None: """Session termination also succeeds when the server answers the DELETE with 204. - This test patches the httpx2 client to return a 204 response for DELETEs. + The in-process server answers the DELETE with 200; a wrapping HTTP transport rewrites that to + 204 on the way back, which is what some servers send. """ - # Save the original delete method to restore later - original_delete = httpx2.AsyncClient.delete + class AnswerDeleteWith204(httpx2.AsyncBaseTransport): + def __init__(self, inner: StreamingASGITransport) -> None: + self.inner = inner - # Mock the client's delete method to return a 204 - async def mock_delete(self: httpx2.AsyncClient, *args: Any, **kwargs: Any) -> httpx2.Response: - # Call the original method to get the real response - response = await original_delete(self, *args, **kwargs) + async def handle_async_request(self, request: httpx2.Request) -> httpx2.Response: + response = await self.inner.handle_async_request(request) + if request.method != "DELETE" or response.status_code != 200: + return response + await response.aread() + return httpx2.Response(204, headers=response.headers, request=request) - # Create a new response with 204 status code but same headers - mocked_response = httpx2.Response( - 204, - headers=response.headers, - content=response.content, - request=response.request, - ) - return mocked_response + async def __aenter__(self) -> AnswerDeleteWith204: + await self.inner.__aenter__() + return self - # Apply the patch to the httpx2 client - monkeypatch.setattr(httpx2.AsyncClient, "delete", mock_delete) + async def __aexit__(self, *args: Any) -> None: + await self.inner.__aexit__(*args) - # Use httpx2 client with event hooks to capture session ID - httpx_client, captured_ids = create_session_id_capturing_client(basic_app) + httpx_client, captured_ids = create_session_id_capturing_client( + basic_app, transport=AnswerDeleteWith204(StreamingASGITransport(basic_app)) + ) async with httpx_client: async with streamable_http_client(f"{BASE_URL}/mcp", http_client=httpx_client) as ( From e8b94867acee4d2752e22c8b7008d703ba694b32 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 5 Sep 2026 00:15:07 +0100 Subject: [PATCH 12/20] Bump pymdown-extensions from 11.0 to 11.0.1 (#3285) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- uv.lock | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/uv.lock b/uv.lock index a391152f..a32918bb 100644 --- a/uv.lock +++ b/uv.lock @@ -555,7 +555,7 @@ name = "exceptiongroup" version = "1.3.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/0b/9f/a65090624ecf468cdca03533906e7c69ed7588582240cfe7cc9e770b50eb/exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88", size = 29749, upload-time = "2025-05-10T17:42:51.123Z" } wheels = [ @@ -2142,15 +2142,15 @@ crypto = [ [[package]] name = "pymdown-extensions" -version = "11.0" +version = "11.0.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "markdown" }, { name = "pyyaml" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/47/67/f1e79672a5f91985577c7984c9709ca110e4fd37fe7fd167b60422e6ccc2/pymdown_extensions-11.0.tar.gz", hash = "sha256:8269cef0247f9e2d0a62fcea10860aba05c1cbab5470fd4b63230b96434dc589", size = 857049, upload-time = "2026-06-23T02:27:45.146Z" } +sdist = { url = "https://files.pythonhosted.org/packages/21/a9/5f0c535ba3b08fe09270c16808e053a968868242ecbd5676d4e3a488bf28/pymdown_extensions-11.0.1.tar.gz", hash = "sha256:dd2905ae6fc5b75582fafb139a1266ffc754705efa902aa50067fa7ff4f94ec0", size = 857113, upload-time = "2026-07-02T17:59:22.955Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/af/b6/1ae53367e28b9cffa3be7574e13fbe4589694272fd47710fbdbafd3d63c6/pymdown_extensions-11.0-py3-none-any.whl", hash = "sha256:fbc4acb641814fa9d17521bbd21a5240ef739a662f11c06330c4b78c93e954d6", size = 269415, upload-time = "2026-06-23T02:27:43.826Z" }, + { url = "https://files.pythonhosted.org/packages/d6/54/da572c98c0b77626a91b5d3b89f0231d8bff5125c225420908632f8b342d/pymdown_extensions-11.0.1-py3-none-any.whl", hash = "sha256:db3943a62bab7e03af1364f0c4083e64b91fb097675a4b6cceccfbe9a77e5eb2", size = 269455, upload-time = "2026-07-02T17:59:21.271Z" }, ] [[package]] From a925e558938720d2a0f032c05ee3d72d26b80546 Mon Sep 17 00:00:00 2001 From: Max <224885523+maxisbey@users.noreply.github.com> Date: Sat, 5 Sep 2026 01:22:35 +0100 Subject: [PATCH 13/20] Bump the locked versions of eight dev and test dependencies (#3449) --- tests/shared/test_streamable_http.py | 4 +- uv.lock | 316 +++++++++++++-------------- 2 files changed, 159 insertions(+), 161 deletions(-) diff --git a/tests/shared/test_streamable_http.py b/tests/shared/test_streamable_http.py index 93700bf0..655d9941 100644 --- a/tests/shared/test_streamable_http.py +++ b/tests/shared/test_streamable_http.py @@ -1405,7 +1405,9 @@ async def _handle_context_list_tools( ) -async def _handle_context_call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult: +async def _handle_context_call_tool( + ctx: ServerRequestContext[Any, Request], params: CallToolRequestParams +) -> CallToolResult: assert params.name in ("echo_headers", "echo_context") assert isinstance(ctx.request, Request) diff --git a/uv.lock b/uv.lock index a32918bb..ebc03ffa 100644 --- a/uv.lock +++ b/uv.lock @@ -436,62 +436,59 @@ toml = [ [[package]] name = "cryptography" -version = "46.0.7" +version = "50.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/47/93/ac8f3d5ff04d54bc814e961a43ae5b0b146154c89c61b47bb07557679b18/cryptography-46.0.7.tar.gz", hash = "sha256:e4cfd68c5f3e0bfdad0d38e023239b96a2fe84146481852dffbcca442c245aa5", size = 750652, upload-time = "2026-04-08T01:57:54.692Z" } +sdist = { url = "https://files.pythonhosted.org/packages/de/41/6cbdcf9142d00fe82836fbb51e503e58088575cf7a0fe1dbff6695bf0840/cryptography-50.0.0.tar.gz", hash = "sha256:eeac2acb5a20ed25e0ad6d1df9891a520b78b404266b6d11778f25d5d691a6c9", size = 880201, upload-time = "2026-07-31T14:25:10.11Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0b/5d/4a8f770695d73be252331e60e526291e3df0c9b27556a90a6b47bccca4c2/cryptography-46.0.7-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:ea42cbe97209df307fdc3b155f1b6fa2577c0defa8f1f7d3be7d31d189108ad4", size = 7179869, upload-time = "2026-04-08T01:56:17.157Z" }, - { url = "https://files.pythonhosted.org/packages/5f/45/6d80dc379b0bbc1f9d1e429f42e4cb9e1d319c7a8201beffd967c516ea01/cryptography-46.0.7-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b36a4695e29fe69215d75960b22577197aca3f7a25b9cf9d165dcfe9d80bc325", size = 4275492, upload-time = "2026-04-08T01:56:19.36Z" }, - { url = "https://files.pythonhosted.org/packages/4a/9a/1765afe9f572e239c3469f2cb429f3ba7b31878c893b246b4b2994ffe2fe/cryptography-46.0.7-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5ad9ef796328c5e3c4ceed237a183f5d41d21150f972455a9d926593a1dcb308", size = 4426670, upload-time = "2026-04-08T01:56:21.415Z" }, - { url = "https://files.pythonhosted.org/packages/8f/3e/af9246aaf23cd4ee060699adab1e47ced3f5f7e7a8ffdd339f817b446462/cryptography-46.0.7-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:73510b83623e080a2c35c62c15298096e2a5dc8d51c3b4e1740211839d0dea77", size = 4280275, upload-time = "2026-04-08T01:56:23.539Z" }, - { url = "https://files.pythonhosted.org/packages/0f/54/6bbbfc5efe86f9d71041827b793c24811a017c6ac0fd12883e4caa86b8ed/cryptography-46.0.7-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cbd5fb06b62bd0721e1170273d3f4d5a277044c47ca27ee257025146c34cbdd1", size = 4928402, upload-time = "2026-04-08T01:56:25.624Z" }, - { url = "https://files.pythonhosted.org/packages/2d/cf/054b9d8220f81509939599c8bdbc0c408dbd2bdd41688616a20731371fe0/cryptography-46.0.7-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:420b1e4109cc95f0e5700eed79908cef9268265c773d3a66f7af1eef53d409ef", size = 4459985, upload-time = "2026-04-08T01:56:27.309Z" }, - { url = "https://files.pythonhosted.org/packages/f9/46/4e4e9c6040fb01c7467d47217d2f882daddeb8828f7df800cb806d8a2288/cryptography-46.0.7-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:24402210aa54baae71d99441d15bb5a1919c195398a87b563df84468160a65de", size = 3990652, upload-time = "2026-04-08T01:56:29.095Z" }, - { url = "https://files.pythonhosted.org/packages/36/5f/313586c3be5a2fbe87e4c9a254207b860155a8e1f3cca99f9910008e7d08/cryptography-46.0.7-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:8a469028a86f12eb7d2fe97162d0634026d92a21f3ae0ac87ed1c4a447886c83", size = 4279805, upload-time = "2026-04-08T01:56:30.928Z" }, - { url = "https://files.pythonhosted.org/packages/69/33/60dfc4595f334a2082749673386a4d05e4f0cf4df8248e63b2c3437585f2/cryptography-46.0.7-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:9694078c5d44c157ef3162e3bf3946510b857df5a3955458381d1c7cfc143ddb", size = 4892883, upload-time = "2026-04-08T01:56:32.614Z" }, - { url = "https://files.pythonhosted.org/packages/c7/0b/333ddab4270c4f5b972f980adef4faa66951a4aaf646ca067af597f15563/cryptography-46.0.7-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:42a1e5f98abb6391717978baf9f90dc28a743b7d9be7f0751a6f56a75d14065b", size = 4459756, upload-time = "2026-04-08T01:56:34.306Z" }, - { url = "https://files.pythonhosted.org/packages/d2/14/633913398b43b75f1234834170947957c6b623d1701ffc7a9600da907e89/cryptography-46.0.7-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:91bbcb08347344f810cbe49065914fe048949648f6bd5c2519f34619142bbe85", size = 4410244, upload-time = "2026-04-08T01:56:35.977Z" }, - { url = "https://files.pythonhosted.org/packages/10/f2/19ceb3b3dc14009373432af0c13f46aa08e3ce334ec6eff13492e1812ccd/cryptography-46.0.7-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:5d1c02a14ceb9148cc7816249f64f623fbfee39e8c03b3650d842ad3f34d637e", size = 4674868, upload-time = "2026-04-08T01:56:38.034Z" }, - { url = "https://files.pythonhosted.org/packages/1a/bb/a5c213c19ee94b15dfccc48f363738633a493812687f5567addbcbba9f6f/cryptography-46.0.7-cp311-abi3-win32.whl", hash = "sha256:d23c8ca48e44ee015cd0a54aeccdf9f09004eba9fc96f38c911011d9ff1bd457", size = 3026504, upload-time = "2026-04-08T01:56:39.666Z" }, - { url = "https://files.pythonhosted.org/packages/2b/02/7788f9fefa1d060ca68717c3901ae7fffa21ee087a90b7f23c7a603c32ae/cryptography-46.0.7-cp311-abi3-win_amd64.whl", hash = "sha256:397655da831414d165029da9bc483bed2fe0e75dde6a1523ec2fe63f3c46046b", size = 3488363, upload-time = "2026-04-08T01:56:41.893Z" }, - { url = "https://files.pythonhosted.org/packages/7b/56/15619b210e689c5403bb0540e4cb7dbf11a6bf42e483b7644e471a2812b3/cryptography-46.0.7-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:d151173275e1728cf7839aaa80c34fe550c04ddb27b34f48c232193df8db5842", size = 7119671, upload-time = "2026-04-08T01:56:44Z" }, - { url = "https://files.pythonhosted.org/packages/74/66/e3ce040721b0b5599e175ba91ab08884c75928fbeb74597dd10ef13505d2/cryptography-46.0.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:db0f493b9181c7820c8134437eb8b0b4792085d37dbb24da050476ccb664e59c", size = 4268551, upload-time = "2026-04-08T01:56:46.071Z" }, - { url = "https://files.pythonhosted.org/packages/03/11/5e395f961d6868269835dee1bafec6a1ac176505a167f68b7d8818431068/cryptography-46.0.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ebd6daf519b9f189f85c479427bbd6e9c9037862cf8fe89ee35503bd209ed902", size = 4408887, upload-time = "2026-04-08T01:56:47.718Z" }, - { url = "https://files.pythonhosted.org/packages/40/53/8ed1cf4c3b9c8e611e7122fb56f1c32d09e1fff0f1d77e78d9ff7c82653e/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:b7b412817be92117ec5ed95f880defe9cf18a832e8cafacf0a22337dc1981b4d", size = 4271354, upload-time = "2026-04-08T01:56:49.312Z" }, - { url = "https://files.pythonhosted.org/packages/50/46/cf71e26025c2e767c5609162c866a78e8a2915bbcfa408b7ca495c6140c4/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:fbfd0e5f273877695cb93baf14b185f4878128b250cc9f8e617ea0c025dfb022", size = 4905845, upload-time = "2026-04-08T01:56:50.916Z" }, - { url = "https://files.pythonhosted.org/packages/c0/ea/01276740375bac6249d0a971ebdf6b4dc9ead0ee0a34ef3b5a88c1a9b0d4/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:ffca7aa1d00cf7d6469b988c581598f2259e46215e0140af408966a24cf086ce", size = 4444641, upload-time = "2026-04-08T01:56:52.882Z" }, - { url = "https://files.pythonhosted.org/packages/3d/4c/7d258f169ae71230f25d9f3d06caabcff8c3baf0978e2b7d65e0acac3827/cryptography-46.0.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:60627cf07e0d9274338521205899337c5d18249db56865f943cbe753aa96f40f", size = 3967749, upload-time = "2026-04-08T01:56:54.597Z" }, - { url = "https://files.pythonhosted.org/packages/b5/2a/2ea0767cad19e71b3530e4cad9605d0b5e338b6a1e72c37c9c1ceb86c333/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:80406c3065e2c55d7f49a9550fe0c49b3f12e5bfff5dedb727e319e1afb9bf99", size = 4270942, upload-time = "2026-04-08T01:56:56.416Z" }, - { url = "https://files.pythonhosted.org/packages/41/3d/fe14df95a83319af25717677e956567a105bb6ab25641acaa093db79975d/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:c5b1ccd1239f48b7151a65bc6dd54bcfcc15e028c8ac126d3fada09db0e07ef1", size = 4871079, upload-time = "2026-04-08T01:56:58.31Z" }, - { url = "https://files.pythonhosted.org/packages/9c/59/4a479e0f36f8f378d397f4eab4c850b4ffb79a2f0d58704b8fa0703ddc11/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d5f7520159cd9c2154eb61eb67548ca05c5774d39e9c2c4339fd793fe7d097b2", size = 4443999, upload-time = "2026-04-08T01:57:00.508Z" }, - { url = "https://files.pythonhosted.org/packages/28/17/b59a741645822ec6d04732b43c5d35e4ef58be7bfa84a81e5ae6f05a1d33/cryptography-46.0.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fcd8eac50d9138c1d7fc53a653ba60a2bee81a505f9f8850b6b2888555a45d0e", size = 4399191, upload-time = "2026-04-08T01:57:02.654Z" }, - { url = "https://files.pythonhosted.org/packages/59/6a/bb2e166d6d0e0955f1e9ff70f10ec4b2824c9cfcdb4da772c7dd69cc7d80/cryptography-46.0.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:65814c60f8cc400c63131584e3e1fad01235edba2614b61fbfbfa954082db0ee", size = 4655782, upload-time = "2026-04-08T01:57:04.592Z" }, - { url = "https://files.pythonhosted.org/packages/95/b6/3da51d48415bcb63b00dc17c2eff3a651b7c4fed484308d0f19b30e8cb2c/cryptography-46.0.7-cp314-cp314t-win32.whl", hash = "sha256:fdd1736fed309b4300346f88f74cd120c27c56852c3838cab416e7a166f67298", size = 3002227, upload-time = "2026-04-08T01:57:06.91Z" }, - { url = "https://files.pythonhosted.org/packages/32/a8/9f0e4ed57ec9cebe506e58db11ae472972ecb0c659e4d52bbaee80ca340a/cryptography-46.0.7-cp314-cp314t-win_amd64.whl", hash = "sha256:e06acf3c99be55aa3b516397fe42f5855597f430add9c17fa46bf2e0fb34c9bb", size = 3475332, upload-time = "2026-04-08T01:57:08.807Z" }, - { url = "https://files.pythonhosted.org/packages/a7/7f/cd42fc3614386bc0c12f0cb3c4ae1fc2bbca5c9662dfed031514911d513d/cryptography-46.0.7-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:462ad5cb1c148a22b2e3bcc5ad52504dff325d17daf5df8d88c17dda1f75f2a4", size = 7165618, upload-time = "2026-04-08T01:57:10.645Z" }, - { url = "https://files.pythonhosted.org/packages/a5/d0/36a49f0262d2319139d2829f773f1b97ef8aef7f97e6e5bd21455e5a8fb5/cryptography-46.0.7-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:84d4cced91f0f159a7ddacad249cc077e63195c36aac40b4150e7a57e84fffe7", size = 4270628, upload-time = "2026-04-08T01:57:12.885Z" }, - { url = "https://files.pythonhosted.org/packages/8a/6c/1a42450f464dda6ffbe578a911f773e54dd48c10f9895a23a7e88b3e7db5/cryptography-46.0.7-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:128c5edfe5e5938b86b03941e94fac9ee793a94452ad1365c9fc3f4f62216832", size = 4415405, upload-time = "2026-04-08T01:57:14.923Z" }, - { url = "https://files.pythonhosted.org/packages/9a/92/4ed714dbe93a066dc1f4b4581a464d2d7dbec9046f7c8b7016f5286329e2/cryptography-46.0.7-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:5e51be372b26ef4ba3de3c167cd3d1022934bc838ae9eaad7e644986d2a3d163", size = 4272715, upload-time = "2026-04-08T01:57:16.638Z" }, - { url = "https://files.pythonhosted.org/packages/b7/e6/a26b84096eddd51494bba19111f8fffe976f6a09f132706f8f1bf03f51f7/cryptography-46.0.7-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cdf1a610ef82abb396451862739e3fc93b071c844399e15b90726ef7470eeaf2", size = 4918400, upload-time = "2026-04-08T01:57:19.021Z" }, - { url = "https://files.pythonhosted.org/packages/c7/08/ffd537b605568a148543ac3c2b239708ae0bd635064bab41359252ef88ed/cryptography-46.0.7-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1d25aee46d0c6f1a501adcddb2d2fee4b979381346a78558ed13e50aa8a59067", size = 4450634, upload-time = "2026-04-08T01:57:21.185Z" }, - { url = "https://files.pythonhosted.org/packages/16/01/0cd51dd86ab5b9befe0d031e276510491976c3a80e9f6e31810cce46c4ad/cryptography-46.0.7-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:cdfbe22376065ffcf8be74dc9a909f032df19bc58a699456a21712d6e5eabfd0", size = 3985233, upload-time = "2026-04-08T01:57:22.862Z" }, - { url = "https://files.pythonhosted.org/packages/92/49/819d6ed3a7d9349c2939f81b500a738cb733ab62fbecdbc1e38e83d45e12/cryptography-46.0.7-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:abad9dac36cbf55de6eb49badd4016806b3165d396f64925bf2999bcb67837ba", size = 4271955, upload-time = "2026-04-08T01:57:24.814Z" }, - { url = "https://files.pythonhosted.org/packages/80/07/ad9b3c56ebb95ed2473d46df0847357e01583f4c52a85754d1a55e29e4d0/cryptography-46.0.7-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:935ce7e3cfdb53e3536119a542b839bb94ec1ad081013e9ab9b7cfd478b05006", size = 4879888, upload-time = "2026-04-08T01:57:26.88Z" }, - { url = "https://files.pythonhosted.org/packages/b8/c7/201d3d58f30c4c2bdbe9b03844c291feb77c20511cc3586daf7edc12a47b/cryptography-46.0.7-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:35719dc79d4730d30f1c2b6474bd6acda36ae2dfae1e3c16f2051f215df33ce0", size = 4449961, upload-time = "2026-04-08T01:57:29.068Z" }, - { url = "https://files.pythonhosted.org/packages/a5/ef/649750cbf96f3033c3c976e112265c33906f8e462291a33d77f90356548c/cryptography-46.0.7-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7bbc6ccf49d05ac8f7d7b5e2e2c33830d4fe2061def88210a126d130d7f71a85", size = 4401696, upload-time = "2026-04-08T01:57:31.029Z" }, - { url = "https://files.pythonhosted.org/packages/41/52/a8908dcb1a389a459a29008c29966c1d552588d4ae6d43f3a1a4512e0ebe/cryptography-46.0.7-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a1529d614f44b863a7b480c6d000fe93b59acee9c82ffa027cfadc77521a9f5e", size = 4664256, upload-time = "2026-04-08T01:57:33.144Z" }, - { url = "https://files.pythonhosted.org/packages/4b/fa/f0ab06238e899cc3fb332623f337a7364f36f4bb3f2534c2bb95a35b132c/cryptography-46.0.7-cp38-abi3-win32.whl", hash = "sha256:f247c8c1a1fb45e12586afbb436ef21ff1e80670b2861a90353d9b025583d246", size = 3013001, upload-time = "2026-04-08T01:57:34.933Z" }, - { url = "https://files.pythonhosted.org/packages/d2/f1/00ce3bde3ca542d1acd8f8cfa38e446840945aa6363f9b74746394b14127/cryptography-46.0.7-cp38-abi3-win_amd64.whl", hash = "sha256:506c4ff91eff4f82bdac7633318a526b1d1309fc07ca76a3ad182cb5b686d6d3", size = 3472985, upload-time = "2026-04-08T01:57:36.714Z" }, - { url = "https://files.pythonhosted.org/packages/63/0c/dca8abb64e7ca4f6b2978769f6fea5ad06686a190cec381f0a796fdcaaba/cryptography-46.0.7-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:fc9ab8856ae6cf7c9358430e49b368f3108f050031442eaeb6b9d87e4dcf4e4f", size = 3476879, upload-time = "2026-04-08T01:57:38.664Z" }, - { url = "https://files.pythonhosted.org/packages/3a/ea/075aac6a84b7c271578d81a2f9968acb6e273002408729f2ddff517fed4a/cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:d3b99c535a9de0adced13d159c5a9cf65c325601aa30f4be08afd680643e9c15", size = 4219700, upload-time = "2026-04-08T01:57:40.625Z" }, - { url = "https://files.pythonhosted.org/packages/6c/7b/1c55db7242b5e5612b29fc7a630e91ee7a6e3c8e7bf5406d22e206875fbd/cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:d02c738dacda7dc2a74d1b2b3177042009d5cab7c7079db74afc19e56ca1b455", size = 4385982, upload-time = "2026-04-08T01:57:42.725Z" }, - { url = "https://files.pythonhosted.org/packages/cb/da/9870eec4b69c63ef5925bf7d8342b7e13bc2ee3d47791461c4e49ca212f4/cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:04959522f938493042d595a736e7dbdff6eb6cc2339c11465b3ff89343b65f65", size = 4219115, upload-time = "2026-04-08T01:57:44.939Z" }, - { url = "https://files.pythonhosted.org/packages/f4/72/05aa5832b82dd341969e9a734d1812a6aadb088d9eb6f0430fc337cc5a8f/cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:3986ac1dee6def53797289999eabe84798ad7817f3e97779b5061a95b0ee4968", size = 4385479, upload-time = "2026-04-08T01:57:46.86Z" }, - { url = "https://files.pythonhosted.org/packages/20/2a/1b016902351a523aa2bd446b50a5bc1175d7a7d1cf90fe2ef904f9b84ebc/cryptography-46.0.7-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:258514877e15963bd43b558917bc9f54cf7cf866c38aa576ebf47a77ddbc43a4", size = 3412829, upload-time = "2026-04-08T01:57:48.874Z" }, + { url = "https://files.pythonhosted.org/packages/c5/5c/59086b4aac5e879d38ddbcf74e4be7ade89cebc3eb199a55da998c3bb46a/cryptography-50.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:031e2d5dd4bb9caa3ca9c82e5a197fd8ae680232cee62603d1a813f3f07e3d03", size = 4001252, upload-time = "2026-07-31T14:23:33.331Z" }, + { url = "https://files.pythonhosted.org/packages/57/ef/8f2df13c7216bcad3e1c74e07f6e193d93e998e114f524a53877c9af27ad/cryptography-50.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fd9192b7b70c573d7f214eb1ae35e00d359f6f5e4b27c7e21e30de1fc6204645", size = 4719554, upload-time = "2026-07-31T14:23:35.611Z" }, + { url = "https://files.pythonhosted.org/packages/d9/41/029086c34d91052fc3b88bcc8056f709a7c915c7a23b235a54eb800b1c97/cryptography-50.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:06a32a980526a6ab9a4b9bf8f7385800791e2bb960903cb6b530e4817509a3b7", size = 4702130, upload-time = "2026-07-31T14:23:37.635Z" }, + { url = "https://files.pythonhosted.org/packages/7d/ff/b6ce0954962e7f7b969f850a883744197bb3910bdfd7b6da162eab7d9f68/cryptography-50.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:a1b30560f2acc95aa8b2e06e716a13dbfc97314747b80d9707e307f77b40d6b3", size = 4725244, upload-time = "2026-07-31T14:23:39.471Z" }, + { url = "https://files.pythonhosted.org/packages/06/1e/63a1027cb7fec360a182208e1b7767d5aa1fe57be3d6aa856e69a321edc0/cryptography-50.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:8d89f3976b10b4ce31118de72329025f70d2c6ead14a8217c5514dd2c6d5a78f", size = 5342265, upload-time = "2026-07-31T14:23:41.286Z" }, + { url = "https://files.pythonhosted.org/packages/6b/72/a1116d683a6d7ece94590013882515de087edf9ef0e6292aae615a44df73/cryptography-50.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:b42a28c1844fd9de8f3f7d540e36b66f3a9c83fceac7170ebc7a6a19edd9dcae", size = 4734609, upload-time = "2026-07-31T14:23:43.139Z" }, + { url = "https://files.pythonhosted.org/packages/15/37/36a9c479bbe49acea2636c7fd3360d20f7b7e079c300352011c44850b181/cryptography-50.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:900131fafd8aead39ac7dd3a7e833be754c17a95cfd91221636949fe4eb0aa8a", size = 4356517, upload-time = "2026-07-31T14:23:44.939Z" }, + { url = "https://files.pythonhosted.org/packages/32/98/8a151d64367204cbc63ec65d37502f1d9c53cf4bfc6ec3c532614dbec60d/cryptography-50.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:07949c449a1abcf60d1ee6e88956d89404c7df3c8258f46589e912988e551987", size = 4724529, upload-time = "2026-07-31T14:23:46.93Z" }, + { url = "https://files.pythonhosted.org/packages/22/f6/ec13b470172126464a86bf54d2294a46d29837fc51ba3e45d4047946fb5e/cryptography-50.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:f89831ef99dd7dd169ab06d63a831adb9e20a87aac6d380266bbda5823349169", size = 5299852, upload-time = "2026-07-31T14:23:48.851Z" }, + { url = "https://files.pythonhosted.org/packages/da/3a/f05e32c99d440c9bb891ea0e36c9091891e36be5a9a87ab2ee6ea20729f6/cryptography-50.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:82148ec5bddac30b51a5b3c1945075f896fa022cb93f8e4a01e9f6ee95292c5f", size = 4734462, upload-time = "2026-07-31T14:23:50.861Z" }, + { url = "https://files.pythonhosted.org/packages/ca/dc/bd72b26be8953f80625f63151efd38eee71c76ca6cf591c08ff34615a79e/cryptography-50.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1489e263a8048bb8b6a8bac662eb2d402ea5d2b7b4699b72f385f1e2772db105", size = 4852708, upload-time = "2026-07-31T14:23:52.715Z" }, + { url = "https://files.pythonhosted.org/packages/27/20/c930314a2ab476d15dec966ec87e2e9637bb02b06106b12c0396c57bb603/cryptography-50.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:7cec5b856506da6defb290f30c9ee687d5f5e8cb0bd3f6459dde43b0b4fa40ef", size = 5004179, upload-time = "2026-07-31T14:23:54.887Z" }, + { url = "https://files.pythonhosted.org/packages/32/2e/c9db68a0c4bfa28e310707527c0ee3a2bd254104d2e02e68f368e197aa4c/cryptography-50.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:bd1c592e4d5974f0d08d4888e432157adba757c66da0246918e43677fafa2d30", size = 3840395, upload-time = "2026-07-31T14:23:56.677Z" }, + { url = "https://files.pythonhosted.org/packages/c3/fb/951032a3bf22a5697c83183fb6294a4843772947a70e616c57b3ff5f522e/cryptography-50.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:49e7d93abdbd2990caced757e5fade25302f719c3c8fb6e6fff2dde98999fc41", size = 3989258, upload-time = "2026-07-31T14:23:58.881Z" }, + { url = "https://files.pythonhosted.org/packages/d4/67/91eb047e69c5e845f2f14b8a2e4a1aab0f283cb885531e9e22c8adb176bc/cryptography-50.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:19736989797678c6af1e55cd49055cdbcb55d8f6b5583ac5335f933aba9101dc", size = 4700648, upload-time = "2026-07-31T14:24:00.702Z" }, + { url = "https://files.pythonhosted.org/packages/30/82/85f0f7425c856b9f96459411eb12e74ef72df9caf6f8f15bf23a33ff131f/cryptography-50.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:80b63928fa35083b33966ce1efb70e5b9607181e49dcd1c22c8c005e319f667f", size = 4682442, upload-time = "2026-07-31T14:24:02.538Z" }, + { url = "https://files.pythonhosted.org/packages/1a/28/b555a365adff1cca2fbe7b9e487d68a40de6bc67ff2cb587473eb43de0e7/cryptography-50.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:d58c3db7cd6eed54e6c06744db55456b65ebd7492ddeae9c1e93cfca7aa857d3", size = 4707596, upload-time = "2026-07-31T14:24:04.394Z" }, + { url = "https://files.pythonhosted.org/packages/72/d8/f52538140cc719df62a01cf87d1c7142318d235817109d6f4054d7c352d6/cryptography-50.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:df2a58a472f332225671c35b0a830208b86d004f82baa8530fa3782c85646533", size = 5314552, upload-time = "2026-07-31T14:24:06.31Z" }, + { url = "https://files.pythonhosted.org/packages/38/14/6120e5bd7c5aa022ad15424ba4d5c5269d0d9448ed4d55e492ea91e3c1c4/cryptography-50.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:11b74db56cdbe3cdee6e3f6982ecb70334fa10dce99ed58bf7894aaaa3b2a037", size = 4717113, upload-time = "2026-07-31T14:24:08.349Z" }, + { url = "https://files.pythonhosted.org/packages/fa/71/190bf38c3ee2e0f8efc9860ae100c9df4169742eef274b91e7aa1cb133b9/cryptography-50.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f59e38625469987d7ef6d495323c55e7db6c212eaf6112267e0d3b565a2e9c9f", size = 4338580, upload-time = "2026-07-31T14:24:10.227Z" }, + { url = "https://files.pythonhosted.org/packages/3a/63/504ccfbbe61fd8aa983f7f146399cdf034c72c2fc55f5b2dfdcdcdb20c99/cryptography-50.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:ecfed7367f965a0328cfbdd70da860f15441f002f613185668c6e6ebf5a0ac11", size = 4707038, upload-time = "2026-07-31T14:24:12.169Z" }, + { url = "https://files.pythonhosted.org/packages/01/77/2cf79bbfc4d12ca106437a6e170d6aaa01a373e93093118aaaef0e801bd4/cryptography-50.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:9aa87839c383bdbab6ef865787a1fb877af8dd03464c4400322726feaaadfc6d", size = 5273110, upload-time = "2026-07-31T14:24:14.38Z" }, + { url = "https://files.pythonhosted.org/packages/e5/45/8aae2972c520145377ea3559a605a899bebe227bf070b33cdb445929a9b9/cryptography-50.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:6ba6a53445bd3cfa809ef3ef5f1589aa6ba08784a1d962bf47d0940e871dab1c", size = 4716439, upload-time = "2026-07-31T14:24:16.415Z" }, + { url = "https://files.pythonhosted.org/packages/7b/20/4fe50b619a48c2525cc46e2dbc1ac490708d704be5d467bdaac6dc955682/cryptography-50.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3f5735ffe4996d28b809371756219f5354864902a3b9e7c0b9ee87041209fc9c", size = 4837383, upload-time = "2026-07-31T14:24:18.553Z" }, + { url = "https://files.pythonhosted.org/packages/92/91/3a31366e183343d3703f8995c095f5734676bd6938118047e50fcf279eb4/cryptography-50.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:1b4a266766514614f8aa60416e71f2fc6e575d36e7bdc90f644fadb2f4b75b95", size = 4985772, upload-time = "2026-07-31T14:24:20.385Z" }, + { url = "https://files.pythonhosted.org/packages/74/9a/02ffe35b2853d121689871eb5dce862092562b3a1ed5cc98f1aaed441506/cryptography-50.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:12b9c6996425c76ea6c457ace4f3073e715b8c545add07cd1a8f3a4f90691269", size = 3816291, upload-time = "2026-07-31T14:24:22.125Z" }, + { url = "https://files.pythonhosted.org/packages/03/37/73d005be173aff344af30e9fd2a576575cb2391a7101d9cd3842e1fa8cce/cryptography-50.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ccdc4a71a4dabae05de219404f9f4abc38e3b58422177ff93d0da05967dafa07", size = 4036009, upload-time = "2026-07-31T14:24:24.122Z" }, + { url = "https://files.pythonhosted.org/packages/ff/c6/7a6202a534e32103a285b7834a120869557fe198d51d7cfe59754c8bda9c/cryptography-50.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:910e1d2668e7de9648f2bcee30e180db2a6b15c30f887d7c4c93ddf96e3992e3", size = 4745252, upload-time = "2026-07-31T14:24:26.118Z" }, + { url = "https://files.pythonhosted.org/packages/85/4f/0fa8c2f4428198f15d9ff8d63400e27afbf94ce833f6108da1eb3753f945/cryptography-50.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a91296cb61e8df6f86d0c19cc4068228da256bf59bf86049fbd821084565327f", size = 4728939, upload-time = "2026-07-31T14:24:27.994Z" }, + { url = "https://files.pythonhosted.org/packages/d1/63/54dd723490ba2dc09b299682c10b38db38f159728bcaae8c591b8af2f22d/cryptography-50.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:e722f16708d854fe924790e051061f6704a472c3bac347b6fd88033ea8dd0dc5", size = 4748483, upload-time = "2026-07-31T14:24:30.254Z" }, + { url = "https://files.pythonhosted.org/packages/1d/dd/7c77d26285cc7f6991efce64a0f5b4f9383bfa5dd8c5033003eaf7db4cdb/cryptography-50.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:d764dcf130c428ef66786f866dd750f53182bc608813489915e9fc106bb0c82f", size = 5367599, upload-time = "2026-07-31T14:24:32.457Z" }, + { url = "https://files.pythonhosted.org/packages/46/c9/f60aed34c013f317f92817b6c171c2d22a78270fa41109bd4b08af26b194/cryptography-50.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:105110f43a471dbd0060b9c9516cb8a6a79233631a04cc2ba16f28323ac6e025", size = 4762647, upload-time = "2026-07-31T14:24:34.599Z" }, + { url = "https://files.pythonhosted.org/packages/be/f3/f9a0173b139372c3a48ed98154b45cc6b9de17c789d5ab552e621c293609/cryptography-50.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:828743d939e9629bc267b8e2d08d8bb67cd4319c771a33d4b18b22dd8fb7440a", size = 4385197, upload-time = "2026-07-31T14:24:36.647Z" }, + { url = "https://files.pythonhosted.org/packages/d8/36/83bb81f6e569bc38e1e4a7bc80f29b46bb9601920bc455fc8e888f5d5742/cryptography-50.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:2a8183b489dc1f7f80f135780fadc1108f14b31b8a40411c7a5b17425f65f28b", size = 4748095, upload-time = "2026-07-31T14:24:39.493Z" }, + { url = "https://files.pythonhosted.org/packages/6b/16/d3008eff98c764979865834c3d386d4fd041b5f52e7f34fc29ac1a5eb515/cryptography-50.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6e7d61120573a7f2cd94cc095f9e81f6967c61ccdf194285aa143ecec8e0b708", size = 5325948, upload-time = "2026-07-31T14:24:41.556Z" }, + { url = "https://files.pythonhosted.org/packages/9c/f8/d97f9603efda3888187bfdb893f26c41be4735c10631d05d284ee6b047c4/cryptography-50.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:37fdb0d0111f1e2ff07139dfb79f1b49531f8e213c46f1163dd7642979b58c47", size = 4762400, upload-time = "2026-07-31T14:24:43.636Z" }, + { url = "https://files.pythonhosted.org/packages/64/a2/4615c8f7d81a00b1d6e6afe19f694e1543582349fb5f4076f6cb5dc36485/cryptography-50.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c87f62a3d3b9888ed0fdde100ec06aa61ca9cd44bad9057d1dff9a516b5f5bb9", size = 4878208, upload-time = "2026-07-31T14:24:45.522Z" }, + { url = "https://files.pythonhosted.org/packages/d2/1a/efcfb02f91407149a0dacffffab791f7e19bf6385f63b3666dc8b5e5c9c8/cryptography-50.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:65c2c3add92b45fd0709db8594536aea39c2a67af0e27ffcf049c498501140b7", size = 5037050, upload-time = "2026-07-31T14:24:47.697Z" }, + { url = "https://files.pythonhosted.org/packages/57/30/4a22984d4f1bdfb8c054f07a92bc176b97a3134cc1d6c4b3bffb1f3688b4/cryptography-50.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:d24fead1d4d076e1bfb006dcec392074a3cd8d7b4fc8a595aa64073b2b7a96ba", size = 3874135, upload-time = "2026-07-31T14:24:50.085Z" }, + { url = "https://files.pythonhosted.org/packages/9d/3e/e54cde8c01631a5a8226ccd617eab9e57fd5cfdad90f1a9e6bb570794631/cryptography-50.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:5e34edd123674534acd70147f0ca331eaa2c74e6325fb2028c886aa26ba0b68c", size = 3963170, upload-time = "2026-07-31T14:24:51.968Z" }, + { url = "https://files.pythonhosted.org/packages/01/b6/0b9e125e90f3d2dcf599a218a899cda7326a3158cfa258723f0b398b08f6/cryptography-50.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:8eb5e1172eb569ea8a872796576e6a67c276351728b6455d5beb01242b027c6a", size = 4692441, upload-time = "2026-07-31T14:24:53.743Z" }, + { url = "https://files.pythonhosted.org/packages/53/c9/a5151588710785a96d7bc4de27d4cd62f263bbbcb203cfe29df537eb6505/cryptography-50.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:910d11e1a385c654bf738bf3e6b8e6ed5de0f5610fcae2be9e5b398d8081d20e", size = 4699810, upload-time = "2026-07-31T14:24:55.746Z" }, + { url = "https://files.pythonhosted.org/packages/c7/1a/15b92b25eb6ce3089cd49377ae990a0f3ad485a510f968aed1f19dbdcdf2/cryptography-50.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:62598a8a57f815db4c6259a4e97d857dab56697e7de8e8ab02352ab74da1995d", size = 4691924, upload-time = "2026-07-31T14:24:58.082Z" }, + { url = "https://files.pythonhosted.org/packages/62/15/219075012ab13e8905f3cd572204f4acb4b111df787104346b9bc0cea789/cryptography-50.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:07479a1cb08219ab719147e742e76090c9c773321959bb94946fffdd397a6437", size = 4699593, upload-time = "2026-07-31T14:24:59.951Z" }, + { url = "https://files.pythonhosted.org/packages/8e/b5/c2c5fce26f0ee40d21bafe7f191d29a34b35a65ac4fe8a1191d1983612e9/cryptography-50.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:c99c003e088647b8a5b7c145d6f78c335f6348332b62e142d411c4b63d1460b9", size = 3813796, upload-time = "2026-07-31T14:25:02.298Z" }, ] [[package]] @@ -1832,100 +1829,96 @@ wheels = [ [[package]] name = "pillow" -version = "12.1.1" +version = "12.3.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1f/42/5c74462b4fd957fcd7b13b04fb3205ff8349236ea74c7c375766d6c82288/pillow-12.1.1.tar.gz", hash = "sha256:9ad8fa5937ab05218e2b6a4cff30295ad35afd2f83ac592e68c0d871bb0fdbc4", size = 46980264, upload-time = "2026-02-11T04:23:07.146Z" } +sdist = { url = "https://files.pythonhosted.org/packages/1c/3d/bb7fca845737cf9d7dbde16ed1843984665ff2e0a518f5db43e77ec540b9/pillow-12.3.0.tar.gz", hash = "sha256:3b8182a766685eaa002637e28b4ec8d6b18819a0c71f579bf0dbaa5830297cce", size = 47025035, upload-time = "2026-07-01T11:56:38.965Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1d/30/5bd3d794762481f8c8ae9c80e7b76ecea73b916959eb587521358ef0b2f9/pillow-12.1.1-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:1f1625b72740fdda5d77b4def688eb8fd6490975d06b909fd19f13f391e077e0", size = 5304099, upload-time = "2026-02-11T04:20:06.13Z" }, - { url = "https://files.pythonhosted.org/packages/bd/c1/aab9e8f3eeb4490180e357955e15c2ef74b31f64790ff356c06fb6cf6d84/pillow-12.1.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:178aa072084bd88ec759052feca8e56cbb14a60b39322b99a049e58090479713", size = 4657880, upload-time = "2026-02-11T04:20:09.291Z" }, - { url = "https://files.pythonhosted.org/packages/f1/0a/9879e30d56815ad529d3985aeff5af4964202425c27261a6ada10f7cbf53/pillow-12.1.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b66e95d05ba806247aaa1561f080abc7975daf715c30780ff92a20e4ec546e1b", size = 6222587, upload-time = "2026-02-11T04:20:10.82Z" }, - { url = "https://files.pythonhosted.org/packages/5a/5f/a1b72ff7139e4f89014e8d451442c74a774d5c43cd938fb0a9f878576b37/pillow-12.1.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:89c7e895002bbe49cdc5426150377cbbc04767d7547ed145473f496dfa40408b", size = 8027678, upload-time = "2026-02-11T04:20:12.455Z" }, - { url = "https://files.pythonhosted.org/packages/e2/c2/c7cb187dac79a3d22c3ebeae727abee01e077c8c7d930791dc592f335153/pillow-12.1.1-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a5cbdcddad0af3da87cb16b60d23648bc3b51967eb07223e9fed77a82b457c4", size = 6335777, upload-time = "2026-02-11T04:20:14.441Z" }, - { url = "https://files.pythonhosted.org/packages/0c/7b/f9b09a7804ec7336effb96c26d37c29d27225783dc1501b7d62dcef6ae25/pillow-12.1.1-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9f51079765661884a486727f0729d29054242f74b46186026582b4e4769918e4", size = 7027140, upload-time = "2026-02-11T04:20:16.387Z" }, - { url = "https://files.pythonhosted.org/packages/98/b2/2fa3c391550bd421b10849d1a2144c44abcd966daadd2f7c12e19ea988c4/pillow-12.1.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:99c1506ea77c11531d75e3a412832a13a71c7ebc8192ab9e4b2e355555920e3e", size = 6449855, upload-time = "2026-02-11T04:20:18.554Z" }, - { url = "https://files.pythonhosted.org/packages/96/ff/9caf4b5b950c669263c39e96c78c0d74a342c71c4f43fd031bb5cb7ceac9/pillow-12.1.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:36341d06738a9f66c8287cf8b876d24b18db9bd8740fa0672c74e259ad408cff", size = 7151329, upload-time = "2026-02-11T04:20:20.646Z" }, - { url = "https://files.pythonhosted.org/packages/7b/f8/4b24841f582704da675ca535935bccb32b00a6da1226820845fac4a71136/pillow-12.1.1-cp310-cp310-win32.whl", hash = "sha256:6c52f062424c523d6c4db85518774cc3d50f5539dd6eed32b8f6229b26f24d40", size = 6325574, upload-time = "2026-02-11T04:20:22.43Z" }, - { url = "https://files.pythonhosted.org/packages/f8/f9/9f6b01c0881d7036063aa6612ef04c0e2cad96be21325a1e92d0203f8e91/pillow-12.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:c6008de247150668a705a6338156efb92334113421ceecf7438a12c9a12dab23", size = 7032347, upload-time = "2026-02-11T04:20:23.932Z" }, - { url = "https://files.pythonhosted.org/packages/79/13/c7922edded3dcdaf10c59297540b72785620abc0538872c819915746757d/pillow-12.1.1-cp310-cp310-win_arm64.whl", hash = "sha256:1a9b0ee305220b392e1124a764ee4265bd063e54a751a6b62eff69992f457fa9", size = 2453457, upload-time = "2026-02-11T04:20:25.392Z" }, - { url = "https://files.pythonhosted.org/packages/2b/46/5da1ec4a5171ee7bf1a0efa064aba70ba3d6e0788ce3f5acd1375d23c8c0/pillow-12.1.1-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:e879bb6cd5c73848ef3b2b48b8af9ff08c5b71ecda8048b7dd22d8a33f60be32", size = 5304084, upload-time = "2026-02-11T04:20:27.501Z" }, - { url = "https://files.pythonhosted.org/packages/78/93/a29e9bc02d1cf557a834da780ceccd54e02421627200696fcf805ebdc3fb/pillow-12.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:365b10bb9417dd4498c0e3b128018c4a624dc11c7b97d8cc54effe3b096f4c38", size = 4657866, upload-time = "2026-02-11T04:20:29.827Z" }, - { url = "https://files.pythonhosted.org/packages/13/84/583a4558d492a179d31e4aae32eadce94b9acf49c0337c4ce0b70e0a01f2/pillow-12.1.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d4ce8e329c93845720cd2014659ca67eac35f6433fd3050393d85f3ecef0dad5", size = 6232148, upload-time = "2026-02-11T04:20:31.329Z" }, - { url = "https://files.pythonhosted.org/packages/d5/e2/53c43334bbbb2d3b938978532fbda8e62bb6e0b23a26ce8592f36bcc4987/pillow-12.1.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc354a04072b765eccf2204f588a7a532c9511e8b9c7f900e1b64e3e33487090", size = 8038007, upload-time = "2026-02-11T04:20:34.225Z" }, - { url = "https://files.pythonhosted.org/packages/b8/a6/3d0e79c8a9d58150dd98e199d7c1c56861027f3829a3a60b3c2784190180/pillow-12.1.1-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7e7976bf1910a8116b523b9f9f58bf410f3e8aa330cd9a2bb2953f9266ab49af", size = 6345418, upload-time = "2026-02-11T04:20:35.858Z" }, - { url = "https://files.pythonhosted.org/packages/a2/c8/46dfeac5825e600579157eea177be43e2f7ff4a99da9d0d0a49533509ac5/pillow-12.1.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:597bd9c8419bc7c6af5604e55847789b69123bbe25d65cc6ad3012b4f3c98d8b", size = 7034590, upload-time = "2026-02-11T04:20:37.91Z" }, - { url = "https://files.pythonhosted.org/packages/af/bf/e6f65d3db8a8bbfeaf9e13cc0417813f6319863a73de934f14b2229ada18/pillow-12.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2c1fc0f2ca5f96a3c8407e41cca26a16e46b21060fe6d5b099d2cb01412222f5", size = 6458655, upload-time = "2026-02-11T04:20:39.496Z" }, - { url = "https://files.pythonhosted.org/packages/f9/c2/66091f3f34a25894ca129362e510b956ef26f8fb67a0e6417bc5744e56f1/pillow-12.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:578510d88c6229d735855e1f278aa305270438d36a05031dfaae5067cc8eb04d", size = 7159286, upload-time = "2026-02-11T04:20:41.139Z" }, - { url = "https://files.pythonhosted.org/packages/7b/5a/24bc8eb526a22f957d0cec6243146744966d40857e3d8deb68f7902ca6c1/pillow-12.1.1-cp311-cp311-win32.whl", hash = "sha256:7311c0a0dcadb89b36b7025dfd8326ecfa36964e29913074d47382706e516a7c", size = 6328663, upload-time = "2026-02-11T04:20:43.184Z" }, - { url = "https://files.pythonhosted.org/packages/31/03/bef822e4f2d8f9d7448c133d0a18185d3cce3e70472774fffefe8b0ed562/pillow-12.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:fbfa2a7c10cc2623f412753cddf391c7f971c52ca40a3f65dc5039b2939e8563", size = 7031448, upload-time = "2026-02-11T04:20:44.696Z" }, - { url = "https://files.pythonhosted.org/packages/49/70/f76296f53610bd17b2e7d31728b8b7825e3ac3b5b3688b51f52eab7c0818/pillow-12.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:b81b5e3511211631b3f672a595e3221252c90af017e399056d0faabb9538aa80", size = 2453651, upload-time = "2026-02-11T04:20:46.243Z" }, - { url = "https://files.pythonhosted.org/packages/07/d3/8df65da0d4df36b094351dce696f2989bec731d4f10e743b1c5f4da4d3bf/pillow-12.1.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ab323b787d6e18b3d91a72fc99b1a2c28651e4358749842b8f8dfacd28ef2052", size = 5262803, upload-time = "2026-02-11T04:20:47.653Z" }, - { url = "https://files.pythonhosted.org/packages/d6/71/5026395b290ff404b836e636f51d7297e6c83beceaa87c592718747e670f/pillow-12.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:adebb5bee0f0af4909c30db0d890c773d1a92ffe83da908e2e9e720f8edf3984", size = 4657601, upload-time = "2026-02-11T04:20:49.328Z" }, - { url = "https://files.pythonhosted.org/packages/b1/2e/1001613d941c67442f745aff0f7cc66dd8df9a9c084eb497e6a543ee6f7e/pillow-12.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bb66b7cc26f50977108790e2456b7921e773f23db5630261102233eb355a3b79", size = 6234995, upload-time = "2026-02-11T04:20:51.032Z" }, - { url = "https://files.pythonhosted.org/packages/07/26/246ab11455b2549b9233dbd44d358d033a2f780fa9007b61a913c5b2d24e/pillow-12.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aee2810642b2898bb187ced9b349e95d2a7272930796e022efaf12e99dccd293", size = 8045012, upload-time = "2026-02-11T04:20:52.882Z" }, - { url = "https://files.pythonhosted.org/packages/b2/8b/07587069c27be7535ac1fe33874e32de118fbd34e2a73b7f83436a88368c/pillow-12.1.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a0b1cd6232e2b618adcc54d9882e4e662a089d5768cd188f7c245b4c8c44a397", size = 6349638, upload-time = "2026-02-11T04:20:54.444Z" }, - { url = "https://files.pythonhosted.org/packages/ff/79/6df7b2ee763d619cda2fb4fea498e5f79d984dae304d45a8999b80d6cf5c/pillow-12.1.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7aac39bcf8d4770d089588a2e1dd111cbaa42df5a94be3114222057d68336bd0", size = 7041540, upload-time = "2026-02-11T04:20:55.97Z" }, - { url = "https://files.pythonhosted.org/packages/2c/5e/2ba19e7e7236d7529f4d873bdaf317a318896bac289abebd4bb00ef247f0/pillow-12.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ab174cd7d29a62dd139c44bf74b698039328f45cb03b4596c43473a46656b2f3", size = 6462613, upload-time = "2026-02-11T04:20:57.542Z" }, - { url = "https://files.pythonhosted.org/packages/03/03/31216ec124bb5c3dacd74ce8efff4cc7f52643653bad4825f8f08c697743/pillow-12.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:339ffdcb7cbeaa08221cd401d517d4b1fe7a9ed5d400e4a8039719238620ca35", size = 7166745, upload-time = "2026-02-11T04:20:59.196Z" }, - { url = "https://files.pythonhosted.org/packages/1f/e7/7c4552d80052337eb28653b617eafdef39adfb137c49dd7e831b8dc13bc5/pillow-12.1.1-cp312-cp312-win32.whl", hash = "sha256:5d1f9575a12bed9e9eedd9a4972834b08c97a352bd17955ccdebfeca5913fa0a", size = 6328823, upload-time = "2026-02-11T04:21:01.385Z" }, - { url = "https://files.pythonhosted.org/packages/3d/17/688626d192d7261bbbf98846fc98995726bddc2c945344b65bec3a29d731/pillow-12.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:21329ec8c96c6e979cd0dfd29406c40c1d52521a90544463057d2aaa937d66a6", size = 7033367, upload-time = "2026-02-11T04:21:03.536Z" }, - { url = "https://files.pythonhosted.org/packages/ed/fe/a0ef1f73f939b0eca03ee2c108d0043a87468664770612602c63266a43c4/pillow-12.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:af9a332e572978f0218686636610555ae3defd1633597be015ed50289a03c523", size = 2453811, upload-time = "2026-02-11T04:21:05.116Z" }, - { url = "https://files.pythonhosted.org/packages/d5/11/6db24d4bd7685583caeae54b7009584e38da3c3d4488ed4cd25b439de486/pillow-12.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d242e8ac078781f1de88bf823d70c1a9b3c7950a44cdf4b7c012e22ccbcd8e4e", size = 4062689, upload-time = "2026-02-11T04:21:06.804Z" }, - { url = "https://files.pythonhosted.org/packages/33/c0/ce6d3b1fe190f0021203e0d9b5b99e57843e345f15f9ef22fcd43842fd21/pillow-12.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:02f84dfad02693676692746df05b89cf25597560db2857363a208e393429f5e9", size = 4138535, upload-time = "2026-02-11T04:21:08.452Z" }, - { url = "https://files.pythonhosted.org/packages/a0/c6/d5eb6a4fb32a3f9c21a8c7613ec706534ea1cf9f4b3663e99f0d83f6fca8/pillow-12.1.1-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:e65498daf4b583091ccbb2556c7000abf0f3349fcd57ef7adc9a84a394ed29f6", size = 3601364, upload-time = "2026-02-11T04:21:10.194Z" }, - { url = "https://files.pythonhosted.org/packages/14/a1/16c4b823838ba4c9c52c0e6bbda903a3fe5a1bdbf1b8eb4fff7156f3e318/pillow-12.1.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6c6db3b84c87d48d0088943bf33440e0c42370b99b1c2a7989216f7b42eede60", size = 5262561, upload-time = "2026-02-11T04:21:11.742Z" }, - { url = "https://files.pythonhosted.org/packages/bb/ad/ad9dc98ff24f485008aa5cdedaf1a219876f6f6c42a4626c08bc4e80b120/pillow-12.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8b7e5304e34942bf62e15184219a7b5ad4ff7f3bb5cca4d984f37df1a0e1aee2", size = 4657460, upload-time = "2026-02-11T04:21:13.786Z" }, - { url = "https://files.pythonhosted.org/packages/9e/1b/f1a4ea9a895b5732152789326202a82464d5254759fbacae4deea3069334/pillow-12.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:18e5bddd742a44b7e6b1e773ab5db102bd7a94c32555ba656e76d319d19c3850", size = 6232698, upload-time = "2026-02-11T04:21:15.949Z" }, - { url = "https://files.pythonhosted.org/packages/95/f4/86f51b8745070daf21fd2e5b1fe0eb35d4db9ca26e6d58366562fb56a743/pillow-12.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc44ef1f3de4f45b50ccf9136999d71abb99dca7706bc75d222ed350b9fd2289", size = 8041706, upload-time = "2026-02-11T04:21:17.723Z" }, - { url = "https://files.pythonhosted.org/packages/29/9b/d6ecd956bb1266dd1045e995cce9b8d77759e740953a1c9aad9502a0461e/pillow-12.1.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5a8eb7ed8d4198bccbd07058416eeec51686b498e784eda166395a23eb99138e", size = 6346621, upload-time = "2026-02-11T04:21:19.547Z" }, - { url = "https://files.pythonhosted.org/packages/71/24/538bff45bde96535d7d998c6fed1a751c75ac7c53c37c90dc2601b243893/pillow-12.1.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:47b94983da0c642de92ced1702c5b6c292a84bd3a8e1d1702ff923f183594717", size = 7038069, upload-time = "2026-02-11T04:21:21.378Z" }, - { url = "https://files.pythonhosted.org/packages/94/0e/58cb1a6bc48f746bc4cb3adb8cabff73e2742c92b3bf7a220b7cf69b9177/pillow-12.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:518a48c2aab7ce596d3bf79d0e275661b846e86e4d0e7dec34712c30fe07f02a", size = 6460040, upload-time = "2026-02-11T04:21:23.148Z" }, - { url = "https://files.pythonhosted.org/packages/6c/57/9045cb3ff11eeb6c1adce3b2d60d7d299d7b273a2e6c8381a524abfdc474/pillow-12.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a550ae29b95c6dc13cf69e2c9dc5747f814c54eeb2e32d683e5e93af56caa029", size = 7164523, upload-time = "2026-02-11T04:21:25.01Z" }, - { url = "https://files.pythonhosted.org/packages/73/f2/9be9cb99f2175f0d4dbadd6616ce1bf068ee54a28277ea1bf1fbf729c250/pillow-12.1.1-cp313-cp313-win32.whl", hash = "sha256:a003d7422449f6d1e3a34e3dd4110c22148336918ddbfc6a32581cd54b2e0b2b", size = 6332552, upload-time = "2026-02-11T04:21:27.238Z" }, - { url = "https://files.pythonhosted.org/packages/3f/eb/b0834ad8b583d7d9d42b80becff092082a1c3c156bb582590fcc973f1c7c/pillow-12.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:344cf1e3dab3be4b1fa08e449323d98a2a3f819ad20f4b22e77a0ede31f0faa1", size = 7040108, upload-time = "2026-02-11T04:21:29.462Z" }, - { url = "https://files.pythonhosted.org/packages/d5/7d/fc09634e2aabdd0feabaff4a32f4a7d97789223e7c2042fd805ea4b4d2c2/pillow-12.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:5c0dd1636633e7e6a0afe7bf6a51a14992b7f8e60de5789018ebbdfae55b040a", size = 2453712, upload-time = "2026-02-11T04:21:31.072Z" }, - { url = "https://files.pythonhosted.org/packages/19/2a/b9d62794fc8a0dd14c1943df68347badbd5511103e0d04c035ffe5cf2255/pillow-12.1.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0330d233c1a0ead844fc097a7d16c0abff4c12e856c0b325f231820fee1f39da", size = 5264880, upload-time = "2026-02-11T04:21:32.865Z" }, - { url = "https://files.pythonhosted.org/packages/26/9d/e03d857d1347fa5ed9247e123fcd2a97b6220e15e9cb73ca0a8d91702c6e/pillow-12.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5dae5f21afb91322f2ff791895ddd8889e5e947ff59f71b46041c8ce6db790bc", size = 4660616, upload-time = "2026-02-11T04:21:34.97Z" }, - { url = "https://files.pythonhosted.org/packages/f7/ec/8a6d22afd02570d30954e043f09c32772bfe143ba9285e2fdb11284952cd/pillow-12.1.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2e0c664be47252947d870ac0d327fea7e63985a08794758aa8af5b6cb6ec0c9c", size = 6269008, upload-time = "2026-02-11T04:21:36.623Z" }, - { url = "https://files.pythonhosted.org/packages/3d/1d/6d875422c9f28a4a361f495a5f68d9de4a66941dc2c619103ca335fa6446/pillow-12.1.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:691ab2ac363b8217f7d31b3497108fb1f50faab2f75dfb03284ec2f217e87bf8", size = 8073226, upload-time = "2026-02-11T04:21:38.585Z" }, - { url = "https://files.pythonhosted.org/packages/a1/cd/134b0b6ee5eda6dc09e25e24b40fdafe11a520bc725c1d0bbaa5e00bf95b/pillow-12.1.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9e8064fb1cc019296958595f6db671fba95209e3ceb0c4734c9baf97de04b20", size = 6380136, upload-time = "2026-02-11T04:21:40.562Z" }, - { url = "https://files.pythonhosted.org/packages/7a/a9/7628f013f18f001c1b98d8fffe3452f306a70dc6aba7d931019e0492f45e/pillow-12.1.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:472a8d7ded663e6162dafdf20015c486a7009483ca671cece7a9279b512fcb13", size = 7067129, upload-time = "2026-02-11T04:21:42.521Z" }, - { url = "https://files.pythonhosted.org/packages/1e/f8/66ab30a2193b277785601e82ee2d49f68ea575d9637e5e234faaa98efa4c/pillow-12.1.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:89b54027a766529136a06cfebeecb3a04900397a3590fd252160b888479517bf", size = 6491807, upload-time = "2026-02-11T04:21:44.22Z" }, - { url = "https://files.pythonhosted.org/packages/da/0b/a877a6627dc8318fdb84e357c5e1a758c0941ab1ddffdafd231983788579/pillow-12.1.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:86172b0831b82ce4f7877f280055892b31179e1576aa00d0df3bb1bbf8c3e524", size = 7190954, upload-time = "2026-02-11T04:21:46.114Z" }, - { url = "https://files.pythonhosted.org/packages/83/43/6f732ff85743cf746b1361b91665d9f5155e1483817f693f8d57ea93147f/pillow-12.1.1-cp313-cp313t-win32.whl", hash = "sha256:44ce27545b6efcf0fdbdceb31c9a5bdea9333e664cda58a7e674bb74608b3986", size = 6336441, upload-time = "2026-02-11T04:21:48.22Z" }, - { url = "https://files.pythonhosted.org/packages/3b/44/e865ef3986611bb75bfabdf94a590016ea327833f434558801122979cd0e/pillow-12.1.1-cp313-cp313t-win_amd64.whl", hash = "sha256:a285e3eb7a5a45a2ff504e31f4a8d1b12ef62e84e5411c6804a42197c1cf586c", size = 7045383, upload-time = "2026-02-11T04:21:50.015Z" }, - { url = "https://files.pythonhosted.org/packages/a8/c6/f4fb24268d0c6908b9f04143697ea18b0379490cb74ba9e8d41b898bd005/pillow-12.1.1-cp313-cp313t-win_arm64.whl", hash = "sha256:cc7d296b5ea4d29e6570dabeaed58d31c3fea35a633a69679fb03d7664f43fb3", size = 2456104, upload-time = "2026-02-11T04:21:51.633Z" }, - { url = "https://files.pythonhosted.org/packages/03/d0/bebb3ffbf31c5a8e97241476c4cf8b9828954693ce6744b4a2326af3e16b/pillow-12.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:417423db963cb4be8bac3fc1204fe61610f6abeed1580a7a2cbb2fbda20f12af", size = 4062652, upload-time = "2026-02-11T04:21:53.19Z" }, - { url = "https://files.pythonhosted.org/packages/2d/c0/0e16fb0addda4851445c28f8350d8c512f09de27bbb0d6d0bbf8b6709605/pillow-12.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:b957b71c6b2387610f556a7eb0828afbe40b4a98036fc0d2acfa5a44a0c2036f", size = 4138823, upload-time = "2026-02-11T04:22:03.088Z" }, - { url = "https://files.pythonhosted.org/packages/6b/fb/6170ec655d6f6bb6630a013dd7cf7bc218423d7b5fa9071bf63dc32175ae/pillow-12.1.1-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:097690ba1f2efdeb165a20469d59d8bb03c55fb6621eb2041a060ae8ea3e9642", size = 3601143, upload-time = "2026-02-11T04:22:04.909Z" }, - { url = "https://files.pythonhosted.org/packages/59/04/dc5c3f297510ba9a6837cbb318b87dd2b8f73eb41a43cc63767f65cb599c/pillow-12.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2815a87ab27848db0321fb78c7f0b2c8649dee134b7f2b80c6a45c6831d75ccd", size = 5266254, upload-time = "2026-02-11T04:22:07.656Z" }, - { url = "https://files.pythonhosted.org/packages/05/30/5db1236b0d6313f03ebf97f5e17cda9ca060f524b2fcc875149a8360b21c/pillow-12.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f7ed2c6543bad5a7d5530eb9e78c53132f93dfa44a28492db88b41cdab885202", size = 4657499, upload-time = "2026-02-11T04:22:09.613Z" }, - { url = "https://files.pythonhosted.org/packages/6f/18/008d2ca0eb612e81968e8be0bbae5051efba24d52debf930126d7eaacbba/pillow-12.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:652a2c9ccfb556235b2b501a3a7cf3742148cd22e04b5625c5fe057ea3e3191f", size = 6232137, upload-time = "2026-02-11T04:22:11.434Z" }, - { url = "https://files.pythonhosted.org/packages/70/f1/f14d5b8eeb4b2cd62b9f9f847eb6605f103df89ef619ac68f92f748614ea/pillow-12.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d6e4571eedf43af33d0fc233a382a76e849badbccdf1ac438841308652a08e1f", size = 8042721, upload-time = "2026-02-11T04:22:13.321Z" }, - { url = "https://files.pythonhosted.org/packages/5a/d6/17824509146e4babbdabf04d8171491fa9d776f7061ff6e727522df9bd03/pillow-12.1.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b574c51cf7d5d62e9be37ba446224b59a2da26dc4c1bb2ecbe936a4fb1a7cb7f", size = 6347798, upload-time = "2026-02-11T04:22:15.449Z" }, - { url = "https://files.pythonhosted.org/packages/d1/ee/c85a38a9ab92037a75615aba572c85ea51e605265036e00c5b67dfafbfe2/pillow-12.1.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a37691702ed687799de29a518d63d4682d9016932db66d4e90c345831b02fb4e", size = 7039315, upload-time = "2026-02-11T04:22:17.24Z" }, - { url = "https://files.pythonhosted.org/packages/ec/f3/bc8ccc6e08a148290d7523bde4d9a0d6c981db34631390dc6e6ec34cacf6/pillow-12.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f95c00d5d6700b2b890479664a06e754974848afaae5e21beb4d83c106923fd0", size = 6462360, upload-time = "2026-02-11T04:22:19.111Z" }, - { url = "https://files.pythonhosted.org/packages/f6/ab/69a42656adb1d0665ab051eec58a41f169ad295cf81ad45406963105408f/pillow-12.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:559b38da23606e68681337ad74622c4dbba02254fc9cb4488a305dd5975c7eeb", size = 7165438, upload-time = "2026-02-11T04:22:21.041Z" }, - { url = "https://files.pythonhosted.org/packages/02/46/81f7aa8941873f0f01d4b55cc543b0a3d03ec2ee30d617a0448bf6bd6dec/pillow-12.1.1-cp314-cp314-win32.whl", hash = "sha256:03edcc34d688572014ff223c125a3f77fb08091e4607e7745002fc214070b35f", size = 6431503, upload-time = "2026-02-11T04:22:22.833Z" }, - { url = "https://files.pythonhosted.org/packages/40/72/4c245f7d1044b67affc7f134a09ea619d4895333d35322b775b928180044/pillow-12.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:50480dcd74fa63b8e78235957d302d98d98d82ccbfac4c7e12108ba9ecbdba15", size = 7176748, upload-time = "2026-02-11T04:22:24.64Z" }, - { url = "https://files.pythonhosted.org/packages/e4/ad/8a87bdbe038c5c698736e3348af5c2194ffb872ea52f11894c95f9305435/pillow-12.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:5cb1785d97b0c3d1d1a16bc1d710c4a0049daefc4935f3a8f31f827f4d3d2e7f", size = 2544314, upload-time = "2026-02-11T04:22:26.685Z" }, - { url = "https://files.pythonhosted.org/packages/6c/9d/efd18493f9de13b87ede7c47e69184b9e859e4427225ea962e32e56a49bc/pillow-12.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:1f90cff8aa76835cba5769f0b3121a22bd4eb9e6884cfe338216e557a9a548b8", size = 5268612, upload-time = "2026-02-11T04:22:29.884Z" }, - { url = "https://files.pythonhosted.org/packages/f8/f1/4f42eb2b388eb2ffc660dcb7f7b556c1015c53ebd5f7f754965ef997585b/pillow-12.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1f1be78ce9466a7ee64bfda57bdba0f7cc499d9794d518b854816c41bf0aa4e9", size = 4660567, upload-time = "2026-02-11T04:22:31.799Z" }, - { url = "https://files.pythonhosted.org/packages/01/54/df6ef130fa43e4b82e32624a7b821a2be1c5653a5fdad8469687a7db4e00/pillow-12.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:42fc1f4677106188ad9a55562bbade416f8b55456f522430fadab3cef7cd4e60", size = 6269951, upload-time = "2026-02-11T04:22:33.921Z" }, - { url = "https://files.pythonhosted.org/packages/a9/48/618752d06cc44bb4aae8ce0cd4e6426871929ed7b46215638088270d9b34/pillow-12.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:98edb152429ab62a1818039744d8fbb3ccab98a7c29fc3d5fcef158f3f1f68b7", size = 8074769, upload-time = "2026-02-11T04:22:35.877Z" }, - { url = "https://files.pythonhosted.org/packages/c3/bd/f1d71eb39a72fa088d938655afba3e00b38018d052752f435838961127d8/pillow-12.1.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d470ab1178551dd17fdba0fef463359c41aaa613cdcd7ff8373f54be629f9f8f", size = 6381358, upload-time = "2026-02-11T04:22:37.698Z" }, - { url = "https://files.pythonhosted.org/packages/64/ef/c784e20b96674ed36a5af839305f55616f8b4f8aa8eeccf8531a6e312243/pillow-12.1.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6408a7b064595afcab0a49393a413732a35788f2a5092fdc6266952ed67de586", size = 7068558, upload-time = "2026-02-11T04:22:39.597Z" }, - { url = "https://files.pythonhosted.org/packages/73/cb/8059688b74422ae61278202c4e1ad992e8a2e7375227be0a21c6b87ca8d5/pillow-12.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5d8c41325b382c07799a3682c1c258469ea2ff97103c53717b7893862d0c98ce", size = 6493028, upload-time = "2026-02-11T04:22:42.73Z" }, - { url = "https://files.pythonhosted.org/packages/c6/da/e3c008ed7d2dd1f905b15949325934510b9d1931e5df999bb15972756818/pillow-12.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c7697918b5be27424e9ce568193efd13d925c4481dd364e43f5dff72d33e10f8", size = 7191940, upload-time = "2026-02-11T04:22:44.543Z" }, - { url = "https://files.pythonhosted.org/packages/01/4a/9202e8d11714c1fc5951f2e1ef362f2d7fbc595e1f6717971d5dd750e969/pillow-12.1.1-cp314-cp314t-win32.whl", hash = "sha256:d2912fd8114fc5545aa3a4b5576512f64c55a03f3ebcca4c10194d593d43ea36", size = 6438736, upload-time = "2026-02-11T04:22:46.347Z" }, - { url = "https://files.pythonhosted.org/packages/f3/ca/cbce2327eb9885476b3957b2e82eb12c866a8b16ad77392864ad601022ce/pillow-12.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:4ceb838d4bd9dab43e06c363cab2eebf63846d6a4aeaea283bbdfd8f1a8ed58b", size = 7182894, upload-time = "2026-02-11T04:22:48.114Z" }, - { url = "https://files.pythonhosted.org/packages/ec/d2/de599c95ba0a973b94410477f8bf0b6f0b5e67360eb89bcb1ad365258beb/pillow-12.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:7b03048319bfc6170e93bd60728a1af51d3dd7704935feb228c4d4faab35d334", size = 2546446, upload-time = "2026-02-11T04:22:50.342Z" }, - { url = "https://files.pythonhosted.org/packages/56/11/5d43209aa4cb58e0cc80127956ff1796a68b928e6324bbf06ef4db34367b/pillow-12.1.1-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:600fd103672b925fe62ed08e0d874ea34d692474df6f4bf7ebe148b30f89f39f", size = 5228606, upload-time = "2026-02-11T04:22:52.106Z" }, - { url = "https://files.pythonhosted.org/packages/5f/d5/3b005b4e4fda6698b371fa6c21b097d4707585d7db99e98d9b0b87ac612a/pillow-12.1.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:665e1b916b043cef294bc54d47bf02d87e13f769bc4bc5fa225a24b3a6c5aca9", size = 4622321, upload-time = "2026-02-11T04:22:53.827Z" }, - { url = "https://files.pythonhosted.org/packages/df/36/ed3ea2d594356fd8037e5a01f6156c74bc8d92dbb0fa60746cc96cabb6e8/pillow-12.1.1-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:495c302af3aad1ca67420ddd5c7bd480c8867ad173528767d906428057a11f0e", size = 5247579, upload-time = "2026-02-11T04:22:56.094Z" }, - { url = "https://files.pythonhosted.org/packages/54/9a/9cc3e029683cf6d20ae5085da0dafc63148e3252c2f13328e553aaa13cfb/pillow-12.1.1-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8fd420ef0c52c88b5a035a0886f367748c72147b2b8f384c9d12656678dfdfa9", size = 6989094, upload-time = "2026-02-11T04:22:58.288Z" }, - { url = "https://files.pythonhosted.org/packages/00/98/fc53ab36da80b88df0967896b6c4b4cd948a0dc5aa40a754266aa3ae48b3/pillow-12.1.1-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f975aa7ef9684ce7e2c18a3aa8f8e2106ce1e46b94ab713d156b2898811651d3", size = 5313850, upload-time = "2026-02-11T04:23:00.554Z" }, - { url = "https://files.pythonhosted.org/packages/30/02/00fa585abfd9fe9d73e5f6e554dc36cc2b842898cbfc46d70353dae227f8/pillow-12.1.1-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8089c852a56c2966cf18835db62d9b34fef7ba74c726ad943928d494fa7f4735", size = 5963343, upload-time = "2026-02-11T04:23:02.934Z" }, - { url = "https://files.pythonhosted.org/packages/f2/26/c56ce33ca856e358d27fda9676c055395abddb82c35ac0f593877ed4562e/pillow-12.1.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:cb9bb857b2d057c6dfc72ac5f3b44836924ba15721882ef103cecb40d002d80e", size = 7029880, upload-time = "2026-02-11T04:23:04.783Z" }, + { url = "https://files.pythonhosted.org/packages/25/c2/669d88644cddb1485bd9534e63e8cf476c8e51cb3c3a1297677023505c0e/pillow-12.3.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:6c0016e7b354317c4e9e525b937ac8596c38d2d232b419529b9cd7a1cd46e39a", size = 5392418, upload-time = "2026-07-01T11:53:27.808Z" }, + { url = "https://files.pythonhosted.org/packages/6b/ba/3762f376a2948e3036488d773a146e0ae6ecc2ca03ac20e2615bd0b2ba02/pillow-12.3.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:bcc33feacfaefce60c12fd500a277533bdc02b10a19f7f6d348763d8140bbba7", size = 4785287, upload-time = "2026-07-01T11:53:29.761Z" }, + { url = "https://files.pythonhosted.org/packages/07/50/b5d688cc9c52d4482f3d5bcab6ce20bc2a74a85d2343841c907444a3be2c/pillow-12.3.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5594fc43d548a7ed94949d139aa1341b270f1863f11cfd37f5a6c8b778a6b67f", size = 6253754, upload-time = "2026-07-01T11:53:32.298Z" }, + { url = "https://files.pythonhosted.org/packages/4e/89/36f4cd76cf4baf05c50ababb976249153f18c959171c7f6ba09a6f217260/pillow-12.3.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f0606c8bf2cdefea14a43530f7657cbbb7ecf1c4222512492ef4a4434a9501ec", size = 6925605, upload-time = "2026-07-01T11:53:34.487Z" }, + { url = "https://files.pythonhosted.org/packages/eb/c0/4de58cf6633b9e3a6061ef4be6fb91fc3c90b812ece886f531e3c523d777/pillow-12.3.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:85f998ea1848bc6757289e739cfbdda3a04adfd58b02fc018ce54d754a5ce468", size = 6327788, upload-time = "2026-07-01T11:53:36.433Z" }, + { url = "https://files.pythonhosted.org/packages/87/3c/14d53682a19550dbbaf3b598f807d5457646c510805a44c7d7891cd1cd1a/pillow-12.3.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:25b9b82bb22e6e2b3cd07b39c68b7b862001226cb3dff7130d1cb914121b39ed", size = 7036288, upload-time = "2026-07-01T11:53:38.712Z" }, + { url = "https://files.pythonhosted.org/packages/38/1d/36279e3c77efe034e4cc2b0393ee74ffdb5a62391dacbf9b916154f5f0b8/pillow-12.3.0-cp310-cp310-win32.whl", hash = "sha256:37dc8f7bbb66efe481bb60defacef820c950c24713fb44962ed6aa2a50966de1", size = 6472396, upload-time = "2026-07-01T11:53:40.781Z" }, + { url = "https://files.pythonhosted.org/packages/48/7c/8fa0039574c476d7c6fa57dd7c32a130436877c6ec1e5ce1cc8ec44878c1/pillow-12.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:300557495eb45ebb8aec96c2da9c4be642fbf7cd937278b4013ba894ea8eb0eb", size = 7226887, upload-time = "2026-07-01T11:53:42.764Z" }, + { url = "https://files.pythonhosted.org/packages/fa/17/e324be141d173c1c919428066c3259f21c1b8982e564e01a4a81e96dbdcf/pillow-12.3.0-cp310-cp310-win_arm64.whl", hash = "sha256:514435a37670e3e5e08f3945b68718b6ed329bb84367777e16f9f4dfe1e61a0f", size = 2568039, upload-time = "2026-07-01T11:53:45.372Z" }, + { url = "https://files.pythonhosted.org/packages/fb/c8/0a78b0e02d7ac54bc03e5321c9220da52f0c2ea83b21f7c40e7f3169c502/pillow-12.3.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:00808c5e14ef63ac5161091d242999076604ff74b883423a11e5d7bbb38bf756", size = 5392415, upload-time = "2026-07-01T11:53:47.162Z" }, + { url = "https://files.pythonhosted.org/packages/b2/5b/a02d30018abd97ced9f5a6c63d28597694a00d066516b9c1c6de45859fc9/pillow-12.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:37d6d0a00072fd2948eb22bce7e1475f34569d90c87c59f7a2ec59541b77f7a6", size = 4785266, upload-time = "2026-07-01T11:53:49.079Z" }, + { url = "https://files.pythonhosted.org/packages/c8/98/766667a4be768150a202836acd9fad19c06824ca86c4286d3cf6b274964e/pillow-12.3.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bcb46e2f9feff8d06323983bd83ed00c201fdcab3d74973e7072a889b3979fcd", size = 6263814, upload-time = "2026-07-01T11:53:51.32Z" }, + { url = "https://files.pythonhosted.org/packages/3b/2d/ede717bc1144f63886c21fd349bb95860b0d1a21149ff16f2bb362b612b6/pillow-12.3.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23d27a3e0307ec2244cc51e7287b919aa68d097504ebe19df4e76a98a3eea5bd", size = 6934408, upload-time = "2026-07-01T11:53:53.487Z" }, + { url = "https://files.pythonhosted.org/packages/a3/48/9c58b685e69d49c31af6c8eb9012055fab7e665785165c84796e2c73ce72/pillow-12.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4f883547d4b7f0495ebe7056b0cc2aea76094e7a4abc8e933540f3271df27d9c", size = 6337160, upload-time = "2026-07-01T11:53:55.457Z" }, + { url = "https://files.pythonhosted.org/packages/ff/fa/dc2a5c0ba6df93f67c31d34b808b7ce440b40cdbf96f0b81cde1d1e6fa93/pillow-12.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:236ff70b9312fb68943c703aa842ca6a758abfa45ac187a5e7c1452e96ef72b5", size = 7045172, upload-time = "2026-07-01T11:53:57.736Z" }, + { url = "https://files.pythonhosted.org/packages/86/a5/444817a4d4c4c2417df00513086ca196f388d8f9ef40c2e4ccd1ad1af54b/pillow-12.3.0-cp311-cp311-win32.whl", hash = "sha256:10e41f0fbf1eec8cfd234b8fe17a4caac7c9d0db4c204d3c173a8f9f6ef3232b", size = 6472232, upload-time = "2026-07-01T11:53:59.767Z" }, + { url = "https://files.pythonhosted.org/packages/63/c6/4bad1b18d132a50b27e1365e1ab163616f7a5bb56d330f66f9d1d9d4f9d4/pillow-12.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:8e95e1385e4998ae9694eeaa4730ba5457ff61185b3a55e2e7bea0880aef452a", size = 7233653, upload-time = "2026-07-01T11:54:02.066Z" }, + { url = "https://files.pythonhosted.org/packages/fd/16/00f91ab7760dc842f5aad55217e80fc4a7067a0604535249bc8a2d6d9870/pillow-12.3.0-cp311-cp311-win_arm64.whl", hash = "sha256:ebaea975e03d3141d9d3a507df75c9b3ec90fa9d2ffd07567b3a978d9d790b26", size = 2568195, upload-time = "2026-07-01T11:54:04.622Z" }, + { url = "https://files.pythonhosted.org/packages/37/bf/fb3ebff8ddcb76aac5a01389251bbbb9519922a9b520d8247c1ca864a25d/pillow-12.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ba09209fbe443b4acccebe845d8a138b89a8f4fbaeedd44953490b5315d5e965", size = 5345969, upload-time = "2026-07-01T11:54:06.397Z" }, + { url = "https://files.pythonhosted.org/packages/d8/66/9a386a92561f402389a4fc70c18838bf6d35eb5eb5c6850b4b2dc64f5048/pillow-12.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffd0c5368496f41b0944be820fcb7a838aa6e623d250b01acf2643939c3f99d7", size = 4780323, upload-time = "2026-07-01T11:54:09.351Z" }, + { url = "https://files.pythonhosted.org/packages/25/27/ac8f99618ffd3dde21db0f4d4b1d2ab00c0880595bfd17df103f7f39fd0c/pillow-12.3.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d9c7f76c0673154f044e9d78c8655fb4213f6ca31a836df48b40fe5d187717b9", size = 6266838, upload-time = "2026-07-01T11:54:11.71Z" }, + { url = "https://files.pythonhosted.org/packages/84/21/a35af28dcc61f37ed850a2d64c65c701321dfbf25085e469d5559360cbbf/pillow-12.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:78cb2c6865a35ab8ff8b75fd122f6033b92a62c82801110e48ddd6c936a45d91", size = 6940830, upload-time = "2026-07-01T11:54:13.732Z" }, + { url = "https://files.pythonhosted.org/packages/eb/51/8b08617af3ad95e33ce6d7dd2c99ed6c8298f7fb131636303956be022e25/pillow-12.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e491916b378fba47242221bb9ead245211b70d504f495d105d17b14a24b4907c", size = 6344383, upload-time = "2026-07-01T11:54:15.756Z" }, + { url = "https://files.pythonhosted.org/packages/1d/72/cf78ac9780bb93c28328f408973845a309d4d145041665f734572ced1b52/pillow-12.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0dd2064cbc55aaec028ef5fbb60fa47bb6c3e7918e07ff17935284b227a9d2df", size = 7052934, upload-time = "2026-07-01T11:54:17.721Z" }, + { url = "https://files.pythonhosted.org/packages/20/20/25e0f4dc178a6bc0696793720055519a0de89e7661dae886992decbd2f81/pillow-12.3.0-cp312-cp312-win32.whl", hash = "sha256:dbce0b29841537a2fa4a214c2bbf14de3587c9680caa9b4e217568472490b28f", size = 6472684, upload-time = "2026-07-01T11:54:19.839Z" }, + { url = "https://files.pythonhosted.org/packages/45/89/da2f7971a317f83d807fdd4065c0af40208e59e692cc43d315a71a0e96d1/pillow-12.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a2b55dd6b2a4c4b7d87ffa56bdb33fdc5fdb9a462173861a7bc097f17d91cb09", size = 7227137, upload-time = "2026-07-01T11:54:22.025Z" }, + { url = "https://files.pythonhosted.org/packages/de/47/4845a0a6c0dbf1db8456bd9fc791f13c5ced7ced20606d08a0aacfd25b49/pillow-12.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:331b624368d4f1d069149002f25f44bc61c8919ce8ddb3c45bdad8f6e2d89510", size = 2568267, upload-time = "2026-07-01T11:54:24.051Z" }, + { url = "https://files.pythonhosted.org/packages/9d/ac/31fb64e1e7efb5a4b50cd3d92049ba89ac6e4d8d3bb6a74e15048ca3353e/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:21900ce7ba264168cd50defae43cd75d25c833ad4ad6e73ffc5596d12e25ac89", size = 4161684, upload-time = "2026-07-01T11:54:25.934Z" }, + { url = "https://files.pythonhosted.org/packages/87/b4/9805e23d2b4d77842b468513841fda254ee42f0289d25088340e4ff46e2d/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4e8c2a84d977f50b9daed6eeaf3baef67d00d5d74d932288f02cb94518ee3ace", size = 4255487, upload-time = "2026-07-01T11:54:27.935Z" }, + { url = "https://files.pythonhosted.org/packages/df/39/ecf519435a200c693fe053a6ee4d835b41cf963a4dfc2551c4e637cb2a71/pillow-12.3.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:ae26d61dfa7a47befdc7572b521024e8745f3d809bd95ca9505a7bba9ef849ec", size = 3696433, upload-time = "2026-07-01T11:54:29.813Z" }, + { url = "https://files.pythonhosted.org/packages/42/92/2fc3ffad878ae8dd5469ec1bc8eb83b71f48e13efdf68f02709003982a32/pillow-12.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7a743ff716f746fc19a9557f60dab1600d4613255f8a7aeb3cdde4db7eb15a66", size = 5345889, upload-time = "2026-07-01T11:54:31.97Z" }, + { url = "https://files.pythonhosted.org/packages/10/76/8803c13605b763d33d156c4678fc77f8443389c0c51c8aef707bb02015f4/pillow-12.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d69141514cc30b774ceea5e3ed3a6635c8d8a96edf664689b890f4089111fb35", size = 4780109, upload-time = "2026-07-01T11:54:34.026Z" }, + { url = "https://files.pythonhosted.org/packages/1f/01/e18aff37cb0b4aac47ac90f016d347a49aca667ef97f190b06ac2aabc928/pillow-12.3.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f7401aebd7f581d7f83a439d87d474999317ee099218e5ad25d125290990ba65", size = 6263736, upload-time = "2026-07-01T11:54:36.131Z" }, + { url = "https://files.pythonhosted.org/packages/f7/62/de5bdd77d935331f4f802edc11e4d82950f642caad6cb2f949837b8560e2/pillow-12.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0847a763afefb695bc912d7c131e7e0632d4edc1d8698f58ddabec8e46b8b6d3", size = 6937129, upload-time = "2026-07-01T11:54:38.216Z" }, + { url = "https://files.pythonhosted.org/packages/70/4d/105627a13300c5e0df1d174230b32fd1273062c96f7745fd552b945d1e1d/pillow-12.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:571b9fcb07b97ef3a492028fb3d2dc0993ca23a06138b0315286566d29ef718a", size = 6339562, upload-time = "2026-07-01T11:54:40.354Z" }, + { url = "https://files.pythonhosted.org/packages/6b/1d/f13de01a553988ab895ba1c722e06cf3144d4f57656fd5b81b6d881f1179/pillow-12.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:756c768d0c9c2955feb7a56c37ea24aea2e369f8d36a88da270b6a9f19e62b5e", size = 7049439, upload-time = "2026-07-01T11:54:42.489Z" }, + { url = "https://files.pythonhosted.org/packages/c9/f9/066794cca041b969964f779ee5fa66a9498bbf34248ac39c5d7954e4198f/pillow-12.3.0-cp313-cp313-win32.whl", hash = "sha256:a876864214e136f0eb367788dbd7df045f4806801518e2cfe9e13229cfe06d8f", size = 6473287, upload-time = "2026-07-01T11:54:44.9Z" }, + { url = "https://files.pythonhosted.org/packages/a6/9b/7a58e61d62be561da3a356fe2384d4059a6345fc130e23ef1c36a5b81d24/pillow-12.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:1cca606cd25738df4ed873d5ad46bbdb3d83b5cbca291f6b4ff13a4df6b0bbe8", size = 7239691, upload-time = "2026-07-01T11:54:47.141Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b0/c4ed4f0ef8f8fa5ee8351537db6650bb8189f7e118842978dd6589065692/pillow-12.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:b629de27fda84b42cde7edef0d85f13b958b47f6e9bbcbba9b673c562a89bd8b", size = 2568185, upload-time = "2026-07-01T11:54:49.137Z" }, + { url = "https://files.pythonhosted.org/packages/dc/01/001f65b68192f0228cc1dbbc8d2530ab5d58b61037ba0587f946fea607cd/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:9cf95fe4d0f84c82d282745d9bb08ad9f926efa00be4697e767b814ce40d4330", size = 4161736, upload-time = "2026-07-01T11:54:51.156Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d2/0219746d0fd16fc8a84498e79452375be3797d3ce4044596ce565164b84f/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:8728f216dcdb6e6d555cf971cb34076139ad74b31fc2c14da4fafc741c5f6217", size = 4255435, upload-time = "2026-07-01T11:54:53.414Z" }, + { url = "https://files.pythonhosted.org/packages/c8/02/8d0bc62ef0302318c46ff2a512822d2610e81c7aa46c9b3abe6cbaca5ad0/pillow-12.3.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:a45650e8ce7fafffd731db8550230db6b0d306d181a90b67d3e6bca2f1990930", size = 3696262, upload-time = "2026-07-01T11:54:55.739Z" }, + { url = "https://files.pythonhosted.org/packages/85/e2/73c77d218410b14f5f2d565e8a998d5317b7b9c75368d29985139f7a46f0/pillow-12.3.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ba54cfebe86920a559a7c4d6b9050791c20513650a1952ebe3368c7dc70306f8", size = 5350344, upload-time = "2026-07-01T11:54:57.657Z" }, + { url = "https://files.pythonhosted.org/packages/c7/da/32c752228ae345f489e3a42499d817b6c3996da7e8a3bc7a04fc806b243b/pillow-12.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e158cb00350dc278f3b91551101aa7d12415a66ebf2c91d8d5ac14e56ddd3ad0", size = 4780131, upload-time = "2026-07-01T11:54:59.713Z" }, + { url = "https://files.pythonhosted.org/packages/b1/9d/8b2c807dbef61a5197c047afe99823787eb66f63daf9fb2432f91d6f0462/pillow-12.3.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9aeb04d6aef139de265b29683e119b638208f88cf73cdd1658aa07221165321", size = 6263757, upload-time = "2026-07-01T11:55:01.778Z" }, + { url = "https://files.pythonhosted.org/packages/5c/44/c85361f65dbe00eea8576ee467c768d25129989efb76e94f205e9ca9bb46/pillow-12.3.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:251bf95b67017e27b13d82f5b326234ca62d70f9cf4c2b9032de2358a3b12c7b", size = 6936962, upload-time = "2026-07-01T11:55:03.93Z" }, + { url = "https://files.pythonhosted.org/packages/18/7e/e483414b35800b86b6f08dbbc7803fb5cd52c4d6f897f47d53ea2c7e6f65/pillow-12.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fe3cca2e4e8a592be0f269a1ca4835c25199d9f3ce815c8491048f785b0a0198", size = 6339171, upload-time = "2026-07-01T11:55:05.989Z" }, + { url = "https://files.pythonhosted.org/packages/f0/f4/68c491844841ede6bed70189546b3ee9731cf9f2cbad396faff5e1ccba45/pillow-12.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:23aceaa007d6172b02c277f0cd359c79492bbb14f7072b4ede9fbcaf20648130", size = 7048116, upload-time = "2026-07-01T11:55:08.131Z" }, + { url = "https://files.pythonhosted.org/packages/a3/34/77f3f793fed8efc7d243f21b33c5a3f0d1c97ee70346d3db855587e155ff/pillow-12.3.0-cp314-cp314-win32.whl", hash = "sha256:af8d94b0db561cf68b88a267c5c44b49e134f525d0dc2cb7ed413a66bc23559a", size = 6467209, upload-time = "2026-07-01T11:55:10.408Z" }, + { url = "https://files.pythonhosted.org/packages/f1/e0/492879f69d94f91f60fc8cd05ba03650e9520afebb2fb7aa12777d7c7f38/pillow-12.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:fdafc9cce40277e0f7a0feabce0ee50dd2fa1800f3b38015e51296b5e814048d", size = 7237707, upload-time = "2026-07-01T11:55:12.745Z" }, + { url = "https://files.pythonhosted.org/packages/c9/ac/6b11f2875f1c2ac040d84e1bbf9cf22a88038f901ca1037898b280b38365/pillow-12.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:e91206ee562682b51b98ef4b26a6ef48fd84e15fd4c4bc5ec768eb641d206838", size = 2565995, upload-time = "2026-07-01T11:55:14.736Z" }, + { url = "https://files.pythonhosted.org/packages/52/69/c2208e56af9bfc1913afb24020297a691eb1d4ef688474c8a04913f65e04/pillow-12.3.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:164b31cd1a0490ab6efae01aa5df49da7061be0af1b30e035b6e9a1bfe34ee6e", size = 5352503, upload-time = "2026-07-01T11:55:17.076Z" }, + { url = "https://files.pythonhosted.org/packages/07/70/e5686d753e898a45d778ff1718dba8516ead6ab6b95d85fc8c4b70650cf2/pillow-12.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5afb51d599ea772b8365ae807ae557f18bccfe46ab261fd1c2a9ed700fc6eb17", size = 4782956, upload-time = "2026-07-01T11:55:19.448Z" }, + { url = "https://files.pythonhosted.org/packages/d5/37/25c6692f06927ee973ff18c8d9ee98ad0b4d84ee67a09610c2dd1447958e/pillow-12.3.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3edce1d53195db527e0191f84b71d02022de0540bf43a16ed734ed7537b07385", size = 6322855, upload-time = "2026-07-01T11:55:21.613Z" }, + { url = "https://files.pythonhosted.org/packages/cc/91/420637fcb8f1bc11029e403b4538e6694744428d8246118e45719f944556/pillow-12.3.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bf16ba1b4d0b6b7c8e534936632270cf70eb00dbe09005bc345b2677b726855c", size = 6989642, upload-time = "2026-07-01T11:55:24.006Z" }, + { url = "https://files.pythonhosted.org/packages/10/08/b94d7811281ccf0d143a1cf768d1c49e1e54af63e7b708ab2ee3eb87face/pillow-12.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:24870b09b224f7ae3c39ed07d10e819d06f8720bc551847b1d623832b5b0e28d", size = 6391281, upload-time = "2026-07-01T11:55:26.252Z" }, + { url = "https://files.pythonhosted.org/packages/d2/87/24233f785f55474dc02ce3e739c5528a77e3a862e9333d1dd7a25cc31f70/pillow-12.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:30f2aa603c41533cc25c05acd0da21636e84a315768feb631c937177db558931", size = 7096716, upload-time = "2026-07-01T11:55:28.318Z" }, + { url = "https://files.pythonhosted.org/packages/23/26/fcb2f6e37175b04f53570b59937867e2b80ee1685e744023153028fc14f9/pillow-12.3.0-cp314-cp314t-win32.whl", hash = "sha256:4b0a7fe987b14c31ebda6083f74f22b561fd3739bc0ac51e019622e3d72668c7", size = 6474125, upload-time = "2026-07-01T11:55:30.956Z" }, + { url = "https://files.pythonhosted.org/packages/90/de/3634abee5f1c9e13c56787b7d5517b0ba8d6de51700b95578cf338349c9f/pillow-12.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:962864dc93511324d51ddbb5b9f8731bf71675b93ca612a07441896f4688fb8c", size = 7242939, upload-time = "2026-07-01T11:55:34.044Z" }, + { url = "https://files.pythonhosted.org/packages/ce/2a/fd13f8eb24de5714a6eb444a3d67e2842c6c576e159a43793adf23051351/pillow-12.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0740a512dc522224c77d9aa5a8d70d8b7d73fb91f2c21125d8d025d3b8990e45", size = 2567506, upload-time = "2026-07-01T11:55:35.988Z" }, + { url = "https://files.pythonhosted.org/packages/5d/dc/8fdce34ec725a33c81c6ba122b904d6b9024e50ea9ac7bede62fab54506c/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:0feb2e9d6ad6c9e3c06effe9d00f3f1e618a6643273576b016f591e9315a7139", size = 4162063, upload-time = "2026-07-01T11:55:37.941Z" }, + { url = "https://files.pythonhosted.org/packages/76/66/2044b9a63d3b84ff048228dfcb7cd9bf0df983e8470971bf7d4c57b693de/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:9e881fca225083806662a5c43d627d215f258ff43c890f831966c7d7ba9c7402", size = 4255549, upload-time = "2026-07-01T11:55:40.022Z" }, + { url = "https://files.pythonhosted.org/packages/52/7e/1f67e6f4ece6b582ee4b539decbcc9f848dc245a93ed8cd7338bafef72f1/pillow-12.3.0-cp315-cp315-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:4998562bf62a445225f22e07c896bb04b35b1b1f2eb6d760584c9c51d7a5f78c", size = 3696331, upload-time = "2026-07-01T11:55:41.98Z" }, + { url = "https://files.pythonhosted.org/packages/12/40/d306fc2c8e4d45d7f175c77edca7063be7b86fe7fe6e68f4353bf71d808c/pillow-12.3.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:dc624f6bc473dacdf7ef7eb8678d0d08edf15cd94fad6ae5c7d6cc67a4e4902f", size = 5350370, upload-time = "2026-07-01T11:55:44.028Z" }, + { url = "https://files.pythonhosted.org/packages/dd/44/668fb1437e8ce420f62d6106eb66e44a5971602a4d794615bdf79315d82d/pillow-12.3.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:71d6097b330eea8fd15097780c8e89cb1a8ce7838669f48c5bacd6f663dd4701", size = 4780147, upload-time = "2026-07-01T11:55:46.073Z" }, + { url = "https://files.pythonhosted.org/packages/0c/08/93fa2e70e30a2d81547e481b6ee2bb9522117221fb1e0ce4b5df70967677/pillow-12.3.0-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:28ce87c5ab450a9dd970b52e5aca5fe63ed432d18a2eaddd1979a00a1ba24ace", size = 6273659, upload-time = "2026-07-01T11:55:48.264Z" }, + { url = "https://files.pythonhosted.org/packages/f8/6d/043e96ff814fc31a33077e4cba86082167db520c93632afdf2042febbb0c/pillow-12.3.0-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6b02afb9b97f65fbca5f31db6a2a3ba21aa93030225f150fa3f249717e938fb4", size = 6947439, upload-time = "2026-07-01T11:55:50.503Z" }, + { url = "https://files.pythonhosted.org/packages/af/92/ba71d2ee2ac0edf3fa33bd9d5ee9ee080da70b1766f3ca3934f9938ddac9/pillow-12.3.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:1182d52bc2d5e5d7d0949503aa7e36d12f42205dc287e4883f407b1988820d39", size = 6353577, upload-time = "2026-07-01T11:55:52.697Z" }, + { url = "https://files.pythonhosted.org/packages/0f/ce/e63064e2122923ff687c8ad792d0d736a7b3920a56a46982e81a7fdd25d6/pillow-12.3.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:e795b7eb908249c4e43c7c99fac7c2c75dab0c43566e37db472a355f63693d71", size = 7060394, upload-time = "2026-07-01T11:55:55.149Z" }, + { url = "https://files.pythonhosted.org/packages/54/76/a09cc3ccc8d773a7283d34c38bec1708f9e3cc932093cbc4c5e71ac4060b/pillow-12.3.0-cp315-cp315-win32.whl", hash = "sha256:57b3d78c95ba9059768b10e28b813002261d3f3dfc55cc48b0c988f625175827", size = 6467375, upload-time = "2026-07-01T11:55:57.769Z" }, + { url = "https://files.pythonhosted.org/packages/3e/03/1846c49ba3b1d5550392a4bbd06d6fb4578e1cd91a803198b5c90f5f7d53/pillow-12.3.0-cp315-cp315-win_amd64.whl", hash = "sha256:fa4ecea169a355be7a3ade2c783e2ed12f0e40d2c5621cda8b3297faf7fbb9f5", size = 7237048, upload-time = "2026-07-01T11:55:59.975Z" }, + { url = "https://files.pythonhosted.org/packages/fb/bb/89f35dcc79610423f9f195504d7def7f0d1416a711541b42867e25fe3412/pillow-12.3.0-cp315-cp315-win_arm64.whl", hash = "sha256:877c3f311ff35410f690861c4409e7ccbf0cd2f878e50628a28e5a0bb689e658", size = 2566006, upload-time = "2026-07-01T11:56:02.143Z" }, + { url = "https://files.pythonhosted.org/packages/30/88/707027ba09942dfa2c28759b5c222d769290a41c6d20ea60ec250801941f/pillow-12.3.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:e9871b1ffbfa9656b60aeee92ed5136a5742696006fa322b29ea3d8da0ecc9cf", size = 5352509, upload-time = "2026-07-01T11:56:04.2Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6d/00352fa25332c2569cd387851f568cc5a4b75a9adbfb37ac4fbce4c02eec/pillow-12.3.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:53aa02d20d10c3d814d536aa4e5ac9b84ca0ff5a88377963b085ad6822f93e64", size = 4783167, upload-time = "2026-07-01T11:56:06.631Z" }, + { url = "https://files.pythonhosted.org/packages/13/4f/9e049dfa21af7c22427275720e2490267ba8138120add5c4c574deb69782/pillow-12.3.0-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:446c34dcc4324b084a53b705127dc15717b22c5e140ae0a3c38349d4efec071e", size = 6329237, upload-time = "2026-07-01T11:56:08.868Z" }, + { url = "https://files.pythonhosted.org/packages/36/16/cf6eeaae8d0fce8dd390a33437cf68c5d5bd73834a2bc6e2f14efda0ab45/pillow-12.3.0-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf1845d02ad822a369a49f2bb9345b1614744267682e7a03527dc3bf6eea1777", size = 6997047, upload-time = "2026-07-01T11:56:11.379Z" }, + { url = "https://files.pythonhosted.org/packages/1e/69/dbf769bdd55f48bf5733cac28edc6364ffaa072ec9ba336266e4fe66be55/pillow-12.3.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:186941b6aef820ad110fb01fb06eb925374dc3a21b17e37ec9a53b250c6fe2d1", size = 6400440, upload-time = "2026-07-01T11:56:13.908Z" }, + { url = "https://files.pythonhosted.org/packages/a0/e1/ffc9cfc2eea0d178da8018e18e959301ad9d6bc9f3edb7181e748a474b97/pillow-12.3.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:f13c32a3abd6079a66d9526e18dad9b6d280384d49d7c54040cd57b6424041d9", size = 7105895, upload-time = "2026-07-01T11:56:16.575Z" }, + { url = "https://files.pythonhosted.org/packages/18/f0/a5595c1e8c3ae44b9828cb2f0fa8155e5095ef04d6327b8f61cf44a3df85/pillow-12.3.0-cp315-cp315t-win32.whl", hash = "sha256:1657923d2d45afb66526e5b933e5b3052e6bdea196c90d3abb2424e18c77dae8", size = 6474384, upload-time = "2026-07-01T11:56:18.855Z" }, + { url = "https://files.pythonhosted.org/packages/e4/04/62bcd9f844984c5938d3b05264a61d797a29d3e0812341a8204af70bbdee/pillow-12.3.0-cp315-cp315t-win_amd64.whl", hash = "sha256:8cd2f7bdda092d99c9fc2fb7391354f306d01443d22785d0cbfafa2e2c8bb418", size = 7243537, upload-time = "2026-07-01T11:56:21.214Z" }, + { url = "https://files.pythonhosted.org/packages/3d/68/1f3066acedf37673694a7141381d8f811ae97f30d34413d236abe7d489f1/pillow-12.3.0-cp315-cp315t-win_arm64.whl", hash = "sha256:06ff022112bc9cbf83b60f8e028d94ad87b60621706487e65f673de61610ab59", size = 2567491, upload-time = "2026-07-01T11:56:23.506Z" }, + { url = "https://files.pythonhosted.org/packages/75/18/2e8b40223153ccbc60df07f9e8928dc0c76202aa4e55ae9f53962b6510d6/pillow-12.3.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:b3c777e849237620b022f7f297dd67705f9f5cf1685f09f02e46f93e92725468", size = 5302510, upload-time = "2026-07-01T11:56:25.736Z" }, + { url = "https://files.pythonhosted.org/packages/46/3e/51fabf59d5ab801ceab709453d3ab6b180083496579549de4c45ced6528a/pillow-12.3.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:b343699e8308bdc51978310e1c959c584e7869cc8c40780058c87da7781a1e94", size = 4736058, upload-time = "2026-07-01T11:56:28.041Z" }, + { url = "https://files.pythonhosted.org/packages/bf/20/22fe9384b7949e25fb1293bcfc84fb82590ff4ea6b37c95b24d26d793d86/pillow-12.3.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbd139c8447d25dd750ab79ee274cc5e1fe80fc56340ab10b18a195e1b6eca3e", size = 5237776, upload-time = "2026-07-01T11:56:30.263Z" }, + { url = "https://files.pythonhosted.org/packages/08/14/f6ba68107680ffa74b39985f3f30884e41318fbc4250caa423c79b4788bb/pillow-12.3.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e7e480451b9fa137494bccd3a7d69adbe8ac65a87d97be61e11f1b1050a5bac3", size = 5860358, upload-time = "2026-07-01T11:56:32.68Z" }, + { url = "https://files.pythonhosted.org/packages/36/54/0169bc772ec491108b62f644f8ecf1fe5d8ae5ebafde2ee2142210166903/pillow-12.3.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:04f01d28a6aaff387bf842a13be313df23ba0597a44f1a976c9feb3c6ff4711a", size = 7231786, upload-time = "2026-07-01T11:56:35.046Z" }, ] [[package]] @@ -2128,11 +2121,14 @@ wheels = [ [[package]] name = "pyjwt" -version = "2.10.1" +version = "2.13.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e7/46/bd74733ff231675599650d3e47f361794b22ef3e3770998dda30d3b63726/pyjwt-2.10.1.tar.gz", hash = "sha256:3cc5772eb20009233caf06e9d8a0577824723b44e6648ee0a2aedb6cf9381953", size = 87785, upload-time = "2024-11-28T03:43:29.933Z" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3b/81/58d0ac84e1ef3a3843791d6954d94c0b33d526c75eeb1efbce9d0a4c4077/pyjwt-2.13.0.tar.gz", hash = "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423", size = 107515, upload-time = "2026-05-21T19:54:36.618Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/61/ad/689f02752eeec26aed679477e80e632ef1b682313be70793d798c1d5fc8f/PyJWT-2.10.1-py3-none-any.whl", hash = "sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb", size = 22997, upload-time = "2024-11-28T03:43:27.893Z" }, + { url = "https://files.pythonhosted.org/packages/a3/5e/ecf12fdb62546d64385c158514e9b2b671f7832108ef2ecd2020ce0af2d1/pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728", size = 31274, upload-time = "2026-05-21T19:54:35.362Z" }, ] [package.optional-dependencies] @@ -2168,7 +2164,7 @@ wheels = [ [[package]] name = "pytest" -version = "8.4.2" +version = "9.1.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, @@ -2179,9 +2175,9 @@ dependencies = [ { name = "pygments" }, { name = "tomli", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" }, + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, ] [[package]] @@ -2250,20 +2246,20 @@ wheels = [ [[package]] name = "python-dotenv" -version = "1.1.1" +version = "1.2.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f6/b0/4bc07ccd3572a2f9df7e6782f52b0c6c90dcbb803ac4a167702d7d0dfe1e/python_dotenv-1.1.1.tar.gz", hash = "sha256:a8a6399716257f45be6a007360200409fce5cda2661e3dec71d23dc15f6189ab", size = 41978, upload-time = "2025-06-24T04:21:07.341Z" } +sdist = { url = "https://files.pythonhosted.org/packages/6a/53/ed9d74092561d4b01a2ef1349d52cdbc135e526c245f366b089cfca6de49/python_dotenv-1.2.3.tar.gz", hash = "sha256:a20a594dabeaa385725aa239d5244871c143ecb356add8a20fcf23773a6c3a35", size = 58945, upload-time = "2026-08-16T16:54:54.067Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5f/ed/539768cf28c661b5b068d66d96a2f155c4971a5d55684a514c1a0e0dec2f/python_dotenv-1.1.1-py3-none-any.whl", hash = "sha256:31f23644fe2602f88ff55e1f5c79ba497e01224ee7737937930c448e4d0e24dc", size = 20556, upload-time = "2025-06-24T04:21:06.073Z" }, + { url = "https://files.pythonhosted.org/packages/0d/17/c5c6b53ddc18f297992099b3d9ec16c855c0ccc83263a21fe4d1c625ec6c/python_dotenv-1.2.3-py3-none-any.whl", hash = "sha256:904552145e8bfed22162c09dab1c2b9b54fefa7b23ba780f4f26ca0316b0f0d9", size = 22780, upload-time = "2026-08-16T16:54:52.473Z" }, ] [[package]] name = "python-multipart" -version = "0.0.22" +version = "0.0.32" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/94/01/979e98d542a70714b0cb2b6728ed0b7c46792b695e3eaec3e20711271ca3/python_multipart-0.0.22.tar.gz", hash = "sha256:7340bef99a7e0032613f56dc36027b959fd3b30a787ed62d310e951f7c3a3a58", size = 37612, upload-time = "2026-01-25T10:15:56.219Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5b/42/55c32bb9b12693c092ad250a0e82edb5b31ddeda6eb772de5f308b3804ad/python_multipart-0.0.32.tar.gz", hash = "sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e", size = 46881, upload-time = "2026-06-04T16:18:58.647Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1b/d0/397f9626e711ff749a95d96b7af99b9c566a9bb5129b8e4c10fc4d100304/python_multipart-0.0.22-py3-none-any.whl", hash = "sha256:2b2cd894c83d21bf49d702499531c7bafd057d730c201782048f7945d82de155", size = 24579, upload-time = "2026-01-25T10:15:54.811Z" }, + { url = "https://files.pythonhosted.org/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", size = 30042, upload-time = "2026-06-04T16:18:57.319Z" }, ] [[package]] @@ -2636,15 +2632,15 @@ wheels = [ [[package]] name = "starlette" -version = "0.49.1" +version = "1.6.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/1b/3f/507c21db33b66fb027a332f2cb3abbbe924cc3a79ced12f01ed8645955c9/starlette-0.49.1.tar.gz", hash = "sha256:481a43b71e24ed8c43b11ea02f5353d77840e01480881b8cb5a26b8cae64a8cb", size = 2654703, upload-time = "2025-10-28T17:34:10.928Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b5/b4/205b0d5241d934e8add0c38aa924c4f9fb7330834ff11e5444db964ec3f9/starlette-1.6.0.tar.gz", hash = "sha256:d4e3ac5e546444960c710297a3c9fc3f7ebae1b7e963f3d36173b49da535be9b", size = 2716969, upload-time = "2026-08-08T18:27:57.512Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/51/da/545b75d420bb23b5d494b0517757b351963e974e79933f01e05c929f20a6/starlette-0.49.1-py3-none-any.whl", hash = "sha256:d92ce9f07e4a3caa3ac13a79523bd18e3bc0042bb8ff2d759a8e7dd0e1859875", size = 74175, upload-time = "2025-10-28T17:34:09.13Z" }, + { url = "https://files.pythonhosted.org/packages/c8/cb/6a6a47d5b464bd08695d254f3da6e7986cc70c9fa5d778eda57538edfe56/starlette-1.6.0-py3-none-any.whl", hash = "sha256:a86dd39d14bb45f85a3d18525215a9ef0cfd1f192ac793220e72598c90335f0c", size = 75969, upload-time = "2026-08-08T18:27:56.196Z" }, ] [[package]] @@ -2786,11 +2782,11 @@ wheels = [ [[package]] name = "urllib3" -version = "2.6.3" +version = "2.7.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, ] [[package]] From 9771e6b59c775ecb2584849d389ce9efba23a541 Mon Sep 17 00:00:00 2001 From: Max <224885523+maxisbey@users.noreply.github.com> Date: Sat, 5 Sep 2026 01:24:29 +0100 Subject: [PATCH 14/20] Keep following a relative redirect when the endpoint URL carries userinfo (#3450) --- src/mcp/shared/_httpx_utils.py | 8 +++++--- tests/shared/test_httpx_utils.py | 14 ++++++++++++++ 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/src/mcp/shared/_httpx_utils.py b/src/mcp/shared/_httpx_utils.py index e1639e45..940b9f08 100644 --- a/src/mcp/shared/_httpx_utils.py +++ b/src/mcp/shared/_httpx_utils.py @@ -90,8 +90,10 @@ def next_request_within_origin(response: httpx2.Response) -> httpx2.Request | No GET: httpx2 turns a POST into a body-less GET for 301/302/303, which would drop the message), its URL stays within the origin of the request just sent (same scheme, host and port, or http to https on the same host with default - ports), and the Location carries no userinfo (which httpx2 would otherwise - send as Basic auth). None for anything else, including a non-redirect. + ports), and the Location does not bring userinfo of its own (which httpx2 + would otherwise send as Basic auth; userinfo the configured URL already had + is kept by a relative Location and is fine). None for anything else, + including a non-redirect. """ next_request = response.next_request if next_request is None: @@ -99,7 +101,7 @@ def next_request_within_origin(response: httpx2.Response) -> httpx2.Request | No sent = response.request if ( next_request.method != sent.method - or next_request.url.userinfo + or (next_request.url.userinfo and next_request.url.userinfo != sent.url.userinfo) or not _within_origin(sent.url, next_request.url) ): return None diff --git a/tests/shared/test_httpx_utils.py b/tests/shared/test_httpx_utils.py index 5e4eac2f..a267ed79 100644 --- a/tests/shared/test_httpx_utils.py +++ b/tests/shared/test_httpx_utils.py @@ -208,6 +208,20 @@ async def test_redirect_location_with_userinfo_is_not_followed(): assert received == [f"POST {url}"] +async def test_userinfo_of_the_configured_url_kept_by_a_relative_location_is_followed(): + """Userinfo the caller put in the endpoint URL is carried over by a relative Location (URL join + keeps the authority); that is the caller's own credential for the same origin, so the redirect + is followed as httpx2 itself would (SDK-defined).""" + url = "http://user:secret@mcp.example/mcp" + client, received, _ = _recording_client({url: (307, "/mcp/")}) + + async with client, stream_within_origin(client, "POST", url, content=b"payload") as response: + await response.aread() + + assert response.status_code == 200 + assert received == [f"POST {url}", "POST http://user:secret@mcp.example/mcp/"] + + async def test_request_within_origin_returns_a_read_response(): """The non-streaming form hands back a response whose body is already read.""" url = "http://mcp.example/mcp" From 0c9136841ff092987a20700ff9c2c9789bda2f40 Mon Sep 17 00:00:00 2001 From: Max <224885523+maxisbey@users.noreply.github.com> Date: Sat, 5 Sep 2026 03:35:55 +0100 Subject: [PATCH 15/20] Add AuthSettings.validate_token_resource to check a bearer token's resource (#3447) --- docs/deprecated.md | 1 + docs/run/authorization.md | 10 ++- docs_src/authorization/tutorial001.py | 7 ++- docs_src/authorization/tutorial002.py | 7 ++- .../simple-auth/mcp_simple_auth/server.py | 1 + .../mcp_simple_auth/token_verifier.py | 9 ++- examples/snippets/servers/oauth_server.py | 3 +- examples/stories/_shared/auth.py | 1 + examples/stories/bearer_auth/server.py | 2 + .../stories/bearer_auth/server_lowlevel.py | 1 + .../oauth_client_credentials/server.py | 9 ++- .../server_lowlevel.py | 9 ++- src/mcp/server/auth/middleware/bearer_auth.py | 28 ++++++++- src/mcp/server/auth/provider.py | 11 +++- src/mcp/server/auth/settings.py | 30 ++++++++- src/mcp/server/lowlevel/server.py | 5 +- src/mcp/server/mcpserver/server.py | 7 ++- tests/docs_src/test_identity_assertion.py | 1 + tests/interaction/_requirements.py | 6 +- tests/interaction/auth/_harness.py | 5 +- tests/interaction/auth/_provider.py | 7 ++- tests/interaction/auth/test_bearer.py | 62 ++++++++++++++----- tests/interaction/auth/test_lifecycle.py | 25 ++++++++ .../auth/middleware/test_bearer_auth.py | 51 +++++++++++++++ tests/server/auth/test_routes.py | 2 + tests/server/auth/test_settings.py | 34 ++++++++++ 26 files changed, 293 insertions(+), 41 deletions(-) create mode 100644 tests/server/auth/test_settings.py diff --git a/docs/deprecated.md b/docs/deprecated.md index 40de550f..f48f07ac 100644 --- a/docs/deprecated.md +++ b/docs/deprecated.md @@ -136,6 +136,7 @@ These are not spec changes, only SDK usage with a better replacement. They warn | Deprecated | What you do instead | |---|---| | `FuncMetadata.call_fn_with_arg_validation()` | `FuncMetadata.validate_arguments()` and then `FuncMetadata.call_fn()`. Only code that drives `FuncMetadata` directly (a custom `Tool` subclass, say) ever called it. | +| `AuthSettings(resource_server_url=...)` without `validate_token_resource=` | Set it: `True` has the server refuse bearer tokens your verifier does not report as issued for `resource_server_url`, `False` says your verifier checks the token's audience itself (see **[Authorization](run/authorization.md#a-token-verifier)**). Unset behaves as `False`; 3.0 makes `True` the default whenever `resource_server_url` is set. | | `ClientCredentialsOAuthProvider(...)` or `PrivateKeyJWTOAuthProvider(...)` without `issuer=` | Pass `issuer=` naming the authorization server that issued the credentials (see **[Writing OAuth clients](client/oauth-clients.md#machine-to-machine)**). Without it the MCP server decides which authorization server receives them; 3.0 makes the keyword required. | ## Recap diff --git a/docs/run/authorization.md b/docs/run/authorization.md index b7d731b1..dfcbbc9a 100644 --- a/docs/run/authorization.md +++ b/docs/run/authorization.md @@ -18,12 +18,12 @@ That's the whole triangle. Everything on this page is the middle bullet. The SDK has no opinion about what a valid token looks like. You tell it, by implementing **`TokenVerifier`**: -```python title="server.py" hl_lines="12-14 19-24" +```python title="server.py" hl_lines="14-16 21-27" --8<-- "docs_src/authorization/tutorial001.py" ``` * `TokenVerifier` is a protocol with one async method. `verify_token` gets the raw token from the `Authorization` header and returns an **`AccessToken`** if it's valid, `None` if it isn't. There is nothing else to implement. -* This one looks the token up in a table. A real one verifies a JWT signature or calls the authorization server's token-introspection endpoint. That code is yours; the SDK only calls it. +* This one looks the token up in a table; each entry records the resource it was issued for. A real one verifies a JWT signature or calls the authorization server's token-introspection endpoint, and reports who the token was issued for (its `aud`) in `AccessToken.resource`. That code is yours; the SDK only calls it. * `token_verifier=` and `auth=` always travel together. Pass one without the other and `MCPServer(...)` raises a `ValueError` before it ever serves a request. `AuthSettings` is the public face of your resource server: @@ -31,6 +31,10 @@ The SDK has no opinion about what a valid token looks like. You tell it, by impl * `issuer_url`: the authorization server that issues your tokens. * `resource_server_url`: the public URL of this MCP endpoint. It names *which* resource a token is for, and it's where the discovery document lives. * `required_scopes`: every token must carry all of them. +* `validate_token_resource`: refuse any token whose `AccessToken.resource` is not `resource_server_url`. Leaving it unset while `resource_server_url` is set warns (`MCPDeprecationWarning`) and behaves as `False`; 3.0 makes `True` the default for resource servers. + * Turn it on when your authorization server binds tokens to the `resource` the client requested, which MCP clients always send. Keep `resource_server_url` the exact URL clients connect to. + * Leave it off when your authorization server uses its own audience identifiers (an Auth0 API identifier, an Entra application ID) and check `aud` in your verifier instead, returning `None` for a token that isn't for this server. + * If `aud` is a list, put the entry that equals `resource_server_url` in `resource`. !!! tip `examples/servers/simple-auth/` in the SDK repository has an `IntrospectionTokenVerifier` that calls @@ -86,7 +90,7 @@ This document is how a client that has never heard of your server finds its way Inside any handler, **`get_access_token()`** is the `AccessToken` your verifier returned for the current request: -```python title="server.py" hl_lines="4 32-35" +```python title="server.py" hl_lines="4 35-38" --8<-- "docs_src/authorization/tutorial002.py" ``` diff --git a/docs_src/authorization/tutorial001.py b/docs_src/authorization/tutorial001.py index f15f54fd..d1a9db87 100644 --- a/docs_src/authorization/tutorial001.py +++ b/docs_src/authorization/tutorial001.py @@ -4,8 +4,10 @@ from mcp.server import MCPServer from mcp.server.auth.provider import AccessToken, TokenVerifier from mcp.server.auth.settings import AuthSettings +RESOURCE = "http://127.0.0.1:8000/mcp" + KNOWN_TOKENS = { - "alice-token": AccessToken(token="alice-token", client_id="alice", scopes=["notes:read"]), + "alice-token": AccessToken(token="alice-token", client_id="alice", scopes=["notes:read"], resource=RESOURCE), } @@ -19,8 +21,9 @@ mcp = MCPServer( token_verifier=StaticTokenVerifier(), auth=AuthSettings( issuer_url=AnyHttpUrl("https://auth.example.com"), - resource_server_url=AnyHttpUrl("http://127.0.0.1:8000/mcp"), + resource_server_url=AnyHttpUrl(RESOURCE), required_scopes=["notes:read"], + validate_token_resource=True, ), ) diff --git a/docs_src/authorization/tutorial002.py b/docs_src/authorization/tutorial002.py index 55b024f2..c088442a 100644 --- a/docs_src/authorization/tutorial002.py +++ b/docs_src/authorization/tutorial002.py @@ -5,8 +5,10 @@ from mcp.server.auth.middleware.auth_context import get_access_token from mcp.server.auth.provider import AccessToken, TokenVerifier from mcp.server.auth.settings import AuthSettings +RESOURCE = "http://127.0.0.1:8000/mcp" + KNOWN_TOKENS = { - "alice-token": AccessToken(token="alice-token", client_id="alice", scopes=["notes:read"]), + "alice-token": AccessToken(token="alice-token", client_id="alice", scopes=["notes:read"], resource=RESOURCE), } @@ -20,8 +22,9 @@ mcp = MCPServer( token_verifier=StaticTokenVerifier(), auth=AuthSettings( issuer_url=AnyHttpUrl("https://auth.example.com"), - resource_server_url=AnyHttpUrl("http://127.0.0.1:8000/mcp"), + resource_server_url=AnyHttpUrl(RESOURCE), required_scopes=["notes:read"], + validate_token_resource=True, ), ) diff --git a/examples/servers/simple-auth/mcp_simple_auth/server.py b/examples/servers/simple-auth/mcp_simple_auth/server.py index 0320871b..c53df6a5 100644 --- a/examples/servers/simple-auth/mcp_simple_auth/server.py +++ b/examples/servers/simple-auth/mcp_simple_auth/server.py @@ -71,6 +71,7 @@ def create_resource_server(settings: ResourceServerSettings) -> MCPServer: issuer_url=settings.auth_server_url, required_scopes=[settings.mcp_scope], resource_server_url=settings.server_url, + validate_token_resource=True, # tokens must be reported as issued for server_url ), ) # Store settings for later use in run() diff --git a/examples/servers/simple-auth/mcp_simple_auth/token_verifier.py b/examples/servers/simple-auth/mcp_simple_auth/token_verifier.py index 933935d6..687196d1 100644 --- a/examples/servers/simple-auth/mcp_simple_auth/token_verifier.py +++ b/examples/servers/simple-auth/mcp_simple_auth/token_verifier.py @@ -69,12 +69,19 @@ class IntrospectionTokenVerifier(TokenVerifier): logger.warning(f"Token resource validation failed. Expected: {self.resource_url}") return None + # `aud` may be a string or a list; report the entry naming this server when there is + # one, otherwise what the token was issued for, so the server can compare it. + aud: str | list[str] | None = data.get("aud") + audiences = aud if isinstance(aud, list) else [aud] if aud else [] + own = self.resource_url.rstrip("/") + resource = next((a for a in audiences if a.rstrip("/") == own), audiences[0] if audiences else None) + return AccessToken( token=token, client_id=data.get("client_id", "unknown"), scopes=data.get("scope", "").split() if data.get("scope") else [], expires_at=data.get("exp"), - resource=data.get("aud"), # Include resource in token + resource=resource, subject=data.get("sub"), # RFC 7662 subject (resource owner) claims=data, ) diff --git a/examples/snippets/servers/oauth_server.py b/examples/snippets/servers/oauth_server.py index 962ef061..204ede4b 100644 --- a/examples/snippets/servers/oauth_server.py +++ b/examples/snippets/servers/oauth_server.py @@ -24,8 +24,9 @@ mcp = MCPServer( # Auth settings for RFC 9728 Protected Resource Metadata auth=AuthSettings( issuer_url=AnyHttpUrl("https://auth.example.com"), # Authorization Server URL - resource_server_url=AnyHttpUrl("http://localhost:3001"), # This server's URL + resource_server_url=AnyHttpUrl("http://127.0.0.1:8000/mcp"), # This server's URL (mcp.run() default) required_scopes=["user"], + validate_token_resource=True, ), ) diff --git a/examples/stories/_shared/auth.py b/examples/stories/_shared/auth.py index 35e99724..cdbf3fd6 100644 --- a/examples/stories/_shared/auth.py +++ b/examples/stories/_shared/auth.py @@ -169,4 +169,5 @@ def auth_settings( required_scopes=scopes, client_registration_options=ClientRegistrationOptions(enabled=True, valid_scopes=scopes, default_scopes=scopes), identity_assertion_enabled=identity_assertion_enabled, + validate_token_resource=True, ) diff --git a/examples/stories/bearer_auth/server.py b/examples/stories/bearer_auth/server.py index 45c9872c..31a0f77b 100644 --- a/examples/stories/bearer_auth/server.py +++ b/examples/stories/bearer_auth/server.py @@ -28,6 +28,7 @@ class StaticTokenVerifier(TokenVerifier): client_id="demo-client", scopes=[REQUIRED_SCOPE], expires_at=int(time.time()) + 3600, + resource=RESOURCE_URL, subject="demo-user", ) @@ -40,6 +41,7 @@ def build_app() -> Starlette: issuer_url=AnyHttpUrl(ISSUER), resource_server_url=AnyHttpUrl(RESOURCE_URL), required_scopes=[REQUIRED_SCOPE], + validate_token_resource=True, ), ) diff --git a/examples/stories/bearer_auth/server_lowlevel.py b/examples/stories/bearer_auth/server_lowlevel.py index e03cb26d..dd15fa88 100644 --- a/examples/stories/bearer_auth/server_lowlevel.py +++ b/examples/stories/bearer_auth/server_lowlevel.py @@ -46,6 +46,7 @@ def build_app() -> Starlette: issuer_url=AnyHttpUrl(ISSUER), resource_server_url=AnyHttpUrl(RESOURCE_URL), required_scopes=[REQUIRED_SCOPE], + validate_token_resource=True, ), token_verifier=StaticTokenVerifier(), transport_security=NO_DNS_REBIND, diff --git a/examples/stories/oauth_client_credentials/server.py b/examples/stories/oauth_client_credentials/server.py index 7e3d910e..0ccc3e32 100644 --- a/examples/stories/oauth_client_credentials/server.py +++ b/examples/stories/oauth_client_credentials/server.py @@ -66,7 +66,14 @@ def build_app() -> Starlette: if creds != f"{DEMO_CLIENT_ID}:{DEMO_CLIENT_SECRET}": return JSONResponse({"error": "invalid_client"}, status_code=401) access = f"access_{secrets.token_hex(16)}" - issued[access] = AccessToken(token=access, client_id=DEMO_CLIENT_ID, scopes=[DEMO_SCOPE], expires_at=None) + resource = form.get("resource") # RFC 8707: bind the token to the resource the client asked for + issued[access] = AccessToken( + token=access, + client_id=DEMO_CLIENT_ID, + scopes=[DEMO_SCOPE], + expires_at=None, + resource=resource if isinstance(resource, str) else None, + ) body = OAuthToken(access_token=access, token_type="Bearer", expires_in=3600, scope=DEMO_SCOPE) return JSONResponse(body.model_dump(exclude_none=True), headers={"cache-control": "no-store"}) diff --git a/examples/stories/oauth_client_credentials/server_lowlevel.py b/examples/stories/oauth_client_credentials/server_lowlevel.py index cde947e9..5879bb82 100644 --- a/examples/stories/oauth_client_credentials/server_lowlevel.py +++ b/examples/stories/oauth_client_credentials/server_lowlevel.py @@ -63,7 +63,14 @@ def build_app() -> Starlette: if creds != f"{DEMO_CLIENT_ID}:{DEMO_CLIENT_SECRET}": return JSONResponse({"error": "invalid_client"}, status_code=401) access = f"access_{secrets.token_hex(16)}" - issued[access] = AccessToken(token=access, client_id=DEMO_CLIENT_ID, scopes=[DEMO_SCOPE], expires_at=None) + resource = form.get("resource") # RFC 8707: bind the token to the resource the client asked for + issued[access] = AccessToken( + token=access, + client_id=DEMO_CLIENT_ID, + scopes=[DEMO_SCOPE], + expires_at=None, + resource=resource if isinstance(resource, str) else None, + ) body = OAuthToken(access_token=access, token_type="Bearer", expires_in=3600, scope=DEMO_SCOPE) return JSONResponse(body.model_dump(exclude_none=True), headers={"cache-control": "no-store"}) diff --git a/src/mcp/server/auth/middleware/bearer_auth.py b/src/mcp/server/auth/middleware/bearer_auth.py index 29413abf..1a070cd2 100644 --- a/src/mcp/server/auth/middleware/bearer_auth.py +++ b/src/mcp/server/auth/middleware/bearer_auth.py @@ -1,14 +1,17 @@ import json +import logging import time from typing import Any, TypedDict -from pydantic import AnyHttpUrl +from pydantic import AnyHttpUrl, ValidationError from starlette.authentication import AuthCredentials, AuthenticationBackend, SimpleUser from starlette.requests import HTTPConnection from starlette.types import Receive, Scope, Send from mcp.server.auth.provider import AccessToken, TokenVerifier, principal_components +logger = logging.getLogger(__name__) + class AuthenticatedUser(SimpleUser): """User with authentication info.""" @@ -39,10 +42,15 @@ def authorization_context(user: AuthenticatedUser) -> AuthorizationContext: class BearerAuthBackend(AuthenticationBackend): - """Authentication backend that validates Bearer tokens using a TokenVerifier.""" + """Authentication backend that validates Bearer tokens using a TokenVerifier. - def __init__(self, token_verifier: TokenVerifier): + When `resource_server_url` is given, only a token whose `AccessToken.resource` + (its RFC 8707 resource indicator / audience) is that URL is accepted. + """ + + def __init__(self, token_verifier: TokenVerifier, *, resource_server_url: AnyHttpUrl | None = None): self.token_verifier = token_verifier + self.resource_server_url = resource_server_url async def authenticate(self, conn: HTTPConnection): auth_header = next( @@ -63,8 +71,22 @@ class BearerAuthBackend(AuthenticationBackend): if auth_info.expires_at and auth_info.expires_at < int(time.time()): return None + if self.resource_server_url and not self._issued_for_this_resource(auth_info.resource): + logger.warning( + "Bearer token resource %r is not resource_server_url %s", auth_info.resource, self.resource_server_url + ) + return None + return AuthCredentials(auth_info.scopes), AuthenticatedUser(auth_info) + def _issued_for_this_resource(self, resource: str | None) -> bool: + """Compare as URLs (so case and default-port spelling do not matter), a trailing slash aside.""" + try: + token_resource = str(AnyHttpUrl(resource or "")) + except ValidationError: + return False + return token_resource.removesuffix("/") == str(self.resource_server_url).removesuffix("/") + class RequireAuthMiddleware: """Middleware that requires a valid Bearer token in the Authorization header. diff --git a/src/mcp/server/auth/provider.py b/src/mcp/server/auth/provider.py index 644868f3..bc8ba3e5 100644 --- a/src/mcp/server/auth/provider.py +++ b/src/mcp/server/auth/provider.py @@ -46,6 +46,7 @@ class RefreshToken(BaseModel): client_id: str scopes: list[str] expires_at: int | None = None + resource: str | None = None # RFC 8707 resource indicator; propagate to refreshed AccessTokens subject: str | None = None # resource owner; propagate to refreshed AccessTokens @@ -124,7 +125,15 @@ class TokenVerifier(Protocol): """Protocol for verifying bearer tokens.""" async def verify_token(self, token: str) -> AccessToken | None: - """Verify a bearer token and return access info if valid.""" + """Verify a bearer token and return access info if valid. + + Set `AccessToken.resource` to the resource the token was issued for (its RFC 8707 + resource indicator / `aud`; for a list, the entry equal to the server's + `AuthSettings.resource_server_url`). With `AuthSettings.validate_token_resource` the + bearer middleware then refuses any token whose resource is not `resource_server_url`; + without it, confirming the token was issued for this server (for example by passing the + expected audience to your JWT library) is up to the verifier. + """ # NOTE: MCPServer doesn't render any of these types in the user response, so it's diff --git a/src/mcp/server/auth/settings.py b/src/mcp/server/auth/settings.py index ae2083a3..4d0f7f13 100644 --- a/src/mcp/server/auth/settings.py +++ b/src/mcp/server/auth/settings.py @@ -1,4 +1,9 @@ -from pydantic import AnyHttpUrl, BaseModel, ConfigDict, Field +import warnings + +from pydantic import AnyHttpUrl, BaseModel, ConfigDict, Field, model_validator +from typing_extensions import Self + +from mcp.shared.exceptions import MCPDeprecationWarning class ClientRegistrationOptions(BaseModel): @@ -40,3 +45,26 @@ class AuthSettings(BaseModel): description="The URL of the MCP server to be used as the resource identifier " "and base route to look up OAuth Protected Resource Metadata.", ) + validate_token_resource: bool | None = Field( + default=None, + description="Only accept tokens the token verifier reports as issued for `resource_server_url` " + "(`AccessToken.resource`, the RFC 8707 resource indicator). Enable it when your authorization " + "server binds tokens to the `resource` the client requested; set it to False when your token " + "verifier checks the token's audience itself. With `resource_server_url` set, leaving it unset warns " + "and behaves as False; 3.0 makes True the default there.", + ) + + @model_validator(mode="after") + def _check_validate_token_resource(self) -> Self: + if self.validate_token_resource and self.resource_server_url is None: + raise ValueError("validate_token_resource requires resource_server_url") + if self.validate_token_resource is None and self.resource_server_url is not None: + warnings.warn( + "`AuthSettings.validate_token_resource` is not set, so bearer tokens are not checked " + "against `resource_server_url`; it will default to True in 3.0 when `resource_server_url` is " + "set. Set it to True to have the server refuse tokens issued for another resource, or to " + "False if your TokenVerifier validates the token's audience itself.", + MCPDeprecationWarning, + stacklevel=3, + ) + return self diff --git a/src/mcp/server/lowlevel/server.py b/src/mcp/server/lowlevel/server.py index 6df5341e..8a886dcc 100644 --- a/src/mcp/server/lowlevel/server.py +++ b/src/mcp/server/lowlevel/server.py @@ -776,7 +776,10 @@ class Server(Generic[LifespanResultT]): middleware = [ Middleware( AuthenticationMiddleware, - backend=BearerAuthBackend(token_verifier), + backend=BearerAuthBackend( + token_verifier, + resource_server_url=auth.resource_server_url if auth.validate_token_resource else None, + ), ), Middleware(AuthContextMiddleware), ] diff --git a/src/mcp/server/mcpserver/server.py b/src/mcp/server/mcpserver/server.py index 1f3e863d..fbd2c26d 100644 --- a/src/mcp/server/mcpserver/server.py +++ b/src/mcp/server/mcpserver/server.py @@ -1187,7 +1187,12 @@ class MCPServer(Generic[LifespanResultT]): # extract auth info from request (but do not require it) Middleware( AuthenticationMiddleware, - backend=BearerAuthBackend(self._token_verifier), + backend=BearerAuthBackend( + self._token_verifier, + resource_server_url=self.settings.auth.resource_server_url + if self.settings.auth.validate_token_resource + else None, + ), ), # Add the auth context middleware to store # authenticated user in a contextvar diff --git a/tests/docs_src/test_identity_assertion.py b/tests/docs_src/test_identity_assertion.py index 1d69c5eb..3a15ef94 100644 --- a/tests/docs_src/test_identity_assertion.py +++ b/tests/docs_src/test_identity_assertion.py @@ -151,6 +151,7 @@ async def test_the_whole_grant_is_one_token_request() -> None: issuer_url=AnyHttpUrl(tutorial002.ISSUER), resource_server_url=AnyHttpUrl(MCP_SERVER_URL), required_scopes=["notes:read"], + validate_token_resource=True, ), ) diff --git a/tests/interaction/_requirements.py b/tests/interaction/_requirements.py index 11bffba7..235fb65c 100644 --- a/tests/interaction/_requirements.py +++ b/tests/interaction/_requirements.py @@ -2848,11 +2848,11 @@ REQUIREMENTS: dict[str, Requirement] = { source=f"{SPEC_BASE_URL}/basic/authorization#access-token-usage", behavior="The resource server validates that the token audience matches its resource identifier.", transports=("streamable-http",), - note="Auth is enforced at the HTTP layer.", + note="Auth is enforced at the HTTP layer; the conformant tests enable AuthSettings.validate_token_resource.", divergence=Divergence( note=( - "BearerAuthBackend never inspects AccessToken.resource; a token issued for a different " - "resource is accepted. Spec MUST." + "Off by default: without AuthSettings.validate_token_resource the bearer gate does not compare " + "AccessToken.resource with resource_server_url and the check is the token verifier's. Spec MUST." ), ), ), diff --git a/tests/interaction/auth/_harness.py b/tests/interaction/auth/_harness.py index 856a1fe9..16b84b1d 100644 --- a/tests/interaction/auth/_harness.py +++ b/tests/interaction/auth/_harness.py @@ -182,6 +182,7 @@ def auth_settings( required_scopes: Sequence[str] = ("mcp",), valid_scopes: Sequence[str] | None = None, identity_assertion_enabled: bool = False, + validate_token_resource: bool = False, ) -> AuthSettings: """Build `AuthSettings` for the co-hosted authorization + resource server. @@ -194,7 +195,8 @@ def auth_settings( `identity_assertion_enabled` advertises and accepts the SEP-990 ID-JAG grant (RFC 7523 jwt-bearer); the provider must implement `exchange_identity_assertion` for the endpoint to - issue tokens. + issue tokens. `validate_token_resource` makes the bearer gate refuse tokens whose verifier + does not report them as issued for the resource URL. """ required = list(required_scopes) valid = list(valid_scopes) if valid_scopes is not None else required @@ -202,6 +204,7 @@ def auth_settings( issuer_url=AnyHttpUrl(BASE_URL), resource_server_url=AnyHttpUrl(f"{BASE_URL}/mcp"), required_scopes=required, + validate_token_resource=validate_token_resource, client_registration_options=ClientRegistrationOptions( enabled=True, valid_scopes=valid, default_scopes=required ), diff --git a/tests/interaction/auth/_provider.py b/tests/interaction/auth/_provider.py index 0c54d4fd..4e422947 100644 --- a/tests/interaction/auth/_provider.py +++ b/tests/interaction/auth/_provider.py @@ -157,6 +157,7 @@ class InMemoryAuthorizationServerProvider( token=refresh, client_id=client.client_id, scopes=authorization_code.scopes, + resource=authorization_code.resource, ) del self.codes[authorization_code.code] return OAuthToken( @@ -183,9 +184,11 @@ class InMemoryAuthorizationServerProvider( if self._fail_next_refresh: self._fail_next_refresh = False raise TokenError(error="invalid_grant", error_description="refresh denied by harness") - access = self.mint_access_token(client_id=client.client_id, scopes=scopes) + access = self.mint_access_token(client_id=client.client_id, scopes=scopes, resource=refresh_token.resource) new_refresh = f"refresh_{secrets.token_hex(16)}" - self.refresh_tokens[new_refresh] = RefreshToken(token=new_refresh, client_id=client.client_id, scopes=scopes) + self.refresh_tokens[new_refresh] = RefreshToken( + token=new_refresh, client_id=client.client_id, scopes=scopes, resource=refresh_token.resource + ) del self.refresh_tokens[refresh_token.token] return OAuthToken( access_token=access, diff --git a/tests/interaction/auth/test_bearer.py b/tests/interaction/auth/test_bearer.py index c70a27c5..aa42c50d 100644 --- a/tests/interaction/auth/test_bearer.py +++ b/tests/interaction/auth/test_bearer.py @@ -2,9 +2,9 @@ These tests mount only the resource-server side of the auth wiring (a `StaticTokenVerifier` seeded with hand-built tokens, no authorization-server provider) and speak raw HTTP, since -every assertion is about HTTP semantics the SDK `Client` cannot observe: the 401/403 status, -the `WWW-Authenticate` header structure, and that a wrong-audience token reaches the MCP -endpoint behind the gate. The flow side of the same 401 is `test_flow.py`'s flagship test. +every assertion is about HTTP semantics the SDK `Client` cannot observe: the 401/403 status +and the `WWW-Authenticate` header structure. The flow side of the same 401 is `test_flow.py`'s +flagship test. """ import time @@ -24,15 +24,23 @@ from tests.interaction.auth._harness import StaticTokenVerifier, auth_settings pytestmark = pytest.mark.anyio REQUIRED_SCOPE = "mcp:read" +RESOURCE = "http://127.0.0.1:8000/mcp" RESOURCE_METADATA_URL = "http://127.0.0.1:8000/.well-known/oauth-protected-resource/mcp" _FUTURE = int(time.time()) + 3600 _PAST = int(time.time()) - 3600 + TOKENS = { - "tok-valid": AccessToken(token="tok-valid", client_id="c", scopes=[REQUIRED_SCOPE], expires_at=_FUTURE), - "tok-expired": AccessToken(token="tok-expired", client_id="c", scopes=[REQUIRED_SCOPE], expires_at=_PAST), - "tok-noscope": AccessToken(token="tok-noscope", client_id="c", scopes=["other:thing"], expires_at=_FUTURE), + "tok-valid": AccessToken( + token="tok-valid", client_id="c", scopes=[REQUIRED_SCOPE], expires_at=_FUTURE, resource=RESOURCE + ), + "tok-expired": AccessToken( + token="tok-expired", client_id="c", scopes=[REQUIRED_SCOPE], expires_at=_PAST, resource=RESOURCE + ), + "tok-noscope": AccessToken( + token="tok-noscope", client_id="c", scopes=["other:thing"], expires_at=_FUTURE, resource=RESOURCE + ), "tok-wrong-aud": AccessToken( token="tok-wrong-aud", client_id="c", @@ -40,6 +48,7 @@ TOKENS = { expires_at=_FUTURE, resource="https://other.example/mcp", ), + "tok-no-aud": AccessToken(token="tok-no-aud", client_id="c", scopes=[REQUIRED_SCOPE], expires_at=_FUTURE), } @@ -47,7 +56,7 @@ TOKENS = { async def protected() -> AsyncIterator[httpx2.AsyncClient]: """A bearer-gated streamable-HTTP app (resource server only) on the in-process bridge.""" server = Server("rs") - settings = auth_settings(required_scopes=[REQUIRED_SCOPE]) + settings = auth_settings(required_scopes=[REQUIRED_SCOPE], validate_token_resource=True) async with mounted_app(server, auth=settings, token_verifier=StaticTokenVerifier(TOKENS)) as (http, _): yield http @@ -157,19 +166,38 @@ async def test_a_token_missing_a_required_scope_is_answered_403_insufficient_sco @requirement("hosting:auth:aud-validation") -async def test_a_token_with_a_mismatched_audience_is_accepted(protected: httpx2.AsyncClient) -> None: - """A token whose `resource` does not match the server's resource identifier is accepted. - - The spec mandates the resource server validate the token's audience; the bearer backend - never inspects `AccessToken.resource`, so the request passes the gate and the MCP endpoint - serves it. This pins current behaviour with the divergence recorded on the requirement. +@pytest.mark.parametrize("bearer", ["tok-wrong-aud", "tok-no-aud"]) +async def test_a_token_not_issued_for_this_resource_is_answered_401(protected: httpx2.AsyncClient, bearer: str) -> None: + """Spec-mandated audience check, which the SDK performs when `AuthSettings.validate_token_resource` + is set (off by default, the recorded divergence): a token whose verifier-reported `resource` + (RFC 8707) is another URL, or absent, is answered 401 `invalid_token` like an unrecognized token. """ - response = await post_mcp(protected, bearer="tok-wrong-aud") + response = await post_mcp(protected, bearer=bearer) + + assert response.status_code == 401 + assert parse_www_authenticate(response.headers["www-authenticate"])["error"] == "invalid_token" + + +@requirement("hosting:auth:aud-validation") +async def test_a_token_for_another_resource_is_served_when_validate_token_resource_is_off() -> None: + """The recorded divergence: with `AuthSettings` at their defaults the gate does not compare + `AccessToken.resource` with `resource_server_url`, so a token the verifier reports as issued + for another resource still reaches the MCP endpoint (SDK default; the check is the verifier's).""" + settings = auth_settings(required_scopes=[REQUIRED_SCOPE]) + async with mounted_app(Server("rs"), auth=settings, token_verifier=StaticTokenVerifier(TOKENS)) as (http, _): + response = await post_mcp(http, bearer="tok-wrong-aud") assert response.status_code == 200 - assert response.headers["content-type"].startswith("text/event-stream") - # The body is finite SSE: a result event followed by stream close. Pull the JSON-RPC response - # out of the buffered text to prove the MCP endpoint actually answered the initialize request. + + +@requirement("hosting:auth:aud-validation") +async def test_a_token_issued_for_this_resource_is_served(protected: httpx2.AsyncClient) -> None: + """The other half: a token the verifier reports as issued for `resource_server_url` passes the + gate and the MCP endpoint answers the initialize request.""" + response = await post_mcp(protected, bearer="tok-valid") + + assert response.status_code == 200 + # Finite SSE body: pull out the JSON-RPC result to prove the endpoint actually answered. [data] = [line.removeprefix("data: ") for line in response.text.splitlines() if line.startswith("data: ")] assert "protocolVersion" in JSONRPCResponse.model_validate_json(data).result diff --git a/tests/interaction/auth/test_lifecycle.py b/tests/interaction/auth/test_lifecycle.py index 610db62e..32f96bc3 100644 --- a/tests/interaction/auth/test_lifecycle.py +++ b/tests/interaction/auth/test_lifecycle.py @@ -135,6 +135,31 @@ async def test_an_expired_access_token_is_transparently_refreshed_before_the_nex assert storage.tokens.expires_in == 3600 +@requirement("client-auth:refresh:transparent") +async def test_a_refreshed_access_token_is_still_accepted_when_the_server_validates_token_resource() -> None: + """With `AuthSettings.validate_token_resource` on, the access token minted by the refresh grant + still passes the gate: the provider carries the original grant's resource on `RefreshToken` and + onto the refreshed `AccessToken` (SDK-defined propagation), so the request after the refresh is + served rather than answered 401.""" + recorded, on_request = record_requests() + provider = InMemoryAuthorizationServerProvider(issue_expired_first=True) + server = Server("guarded", on_list_tools=list_tools) + settings = auth_settings(validate_token_resource=True) + + with anyio.fail_after(5): + async with connect_with_oauth(server, provider=provider, settings=settings, on_request=on_request) as ( + client, + _, + ): + result = await client.list_tools() + + assert result.tools[0].name == "echo" + assert [form_body(r)["grant_type"] for r in find(recorded, "POST", "/token")] == snapshot( + ["authorization_code", "refresh_token"] + ) + assert {t.resource for t in provider.access_tokens.values()} == {f"{BASE_URL}/mcp"} + + @requirement("client-auth:403-scope-upgrade") async def test_a_403_insufficient_scope_triggers_one_reauthorize_with_the_challenged_scope() -> None: """A 403 `insufficient_scope` challenge is answered by one re-authorize with the challenge's scope. diff --git a/tests/server/auth/middleware/test_bearer_auth.py b/tests/server/auth/middleware/test_bearer_auth.py index 6ab34367..b7efb937 100644 --- a/tests/server/auth/middleware/test_bearer_auth.py +++ b/tests/server/auth/middleware/test_bearer_auth.py @@ -4,6 +4,7 @@ import time from typing import Any, cast import pytest +from pydantic import AnyHttpUrl from starlette.authentication import AuthCredentials from starlette.datastructures import Headers from starlette.requests import Request @@ -273,6 +274,56 @@ class TestBearerAuthBackend: assert user.access_token == valid_access_token +class SingleTokenVerifier: + """A `TokenVerifier` that knows exactly one token.""" + + def __init__(self, access_token: AccessToken) -> None: + self.access_token = access_token + + async def verify_token(self, token: str) -> AccessToken | None: + return self.access_token if token == self.access_token.token else None + + +RS = "https://api.example.com/mcp" + + +@pytest.mark.anyio +@pytest.mark.parametrize( + ("resource_server_url", "token_resource", "accepted"), + [ + (None, "https://other.example.com/mcp", True), # nothing configured to compare against + (None, None, True), + (RS, None, False), # the verifier did not report what the token was issued for + (RS, RS, True), + (RS, RS + "/", True), + (RS, "https://API.EXAMPLE.COM:443/mcp", True), # same URL, different spelling + (RS, "https://api.example.com", False), + (RS, RS + "/child", False), + (RS, "https://api.example.com/other", False), + (RS, "https://other.example.com/mcp", False), + (RS, "api.example.com", False), # not a URL + ], +) +async def test_backend_accepts_only_tokens_issued_for_its_resource( + resource_server_url: str | None, token_resource: str | None, accepted: bool +): + """With `resource_server_url` set, only a token whose `resource` (RFC 8707) is that URL is + accepted and anything else is treated like an unrecognized token (spec-mandated audience + check); without it the verifier's answer stands (SDK-defined, the default wiring).""" + token = AccessToken(token="t", client_id="c", scopes=["read"], resource=token_resource) + backend = BearerAuthBackend( + SingleTokenVerifier(token), + resource_server_url=AnyHttpUrl(resource_server_url) if resource_server_url else None, + ) + + result = await backend.authenticate(Request({"type": "http", "headers": [(b"authorization", b"Bearer t")]})) + + if accepted: + assert result is not None and result[1].access_token == token + else: + assert result is None + + @pytest.mark.anyio class TestRequireAuthMiddleware: """Tests for the RequireAuthMiddleware class.""" diff --git a/tests/server/auth/test_routes.py b/tests/server/auth/test_routes.py index 58685c64..64c2c024 100644 --- a/tests/server/auth/test_routes.py +++ b/tests/server/auth/test_routes.py @@ -53,6 +53,7 @@ def test_auth_settings_preserves_path_less_issuer(): settings = AuthSettings( issuer_url="https://as.example.com", # type: ignore[arg-type] resource_server_url="https://rs.example.com", # type: ignore[arg-type] + validate_token_resource=True, ) assert str(settings.issuer_url) == "https://as.example.com" assert str(settings.resource_server_url) == "https://rs.example.com" @@ -63,6 +64,7 @@ def test_build_metadata_serves_issuer_without_trailing_slash(): settings = AuthSettings( issuer_url="https://as.example.com", # type: ignore[arg-type] resource_server_url="https://rs.example.com", # type: ignore[arg-type] + validate_token_resource=True, ) metadata = build_metadata(settings.issuer_url, None, ClientRegistrationOptions(), RevocationOptions()) diff --git a/tests/server/auth/test_settings.py b/tests/server/auth/test_settings.py new file mode 100644 index 00000000..db7dd26f --- /dev/null +++ b/tests/server/auth/test_settings.py @@ -0,0 +1,34 @@ +import warnings + +import pytest +from pydantic import AnyHttpUrl, ValidationError + +from mcp.server.auth.settings import AuthSettings +from mcp.shared.exceptions import MCPDeprecationWarning + +ISSUER = AnyHttpUrl("https://auth.example.com") +RESOURCE = AnyHttpUrl("https://mcp.example.com/mcp") + + +def test_validate_token_resource_requires_a_resource_server_url(): + """SDK-defined: asking the bearer gate to compare tokens against `resource_server_url` without + configuring one is refused at construction time rather than silently comparing nothing.""" + AuthSettings(issuer_url=ISSUER, resource_server_url=RESOURCE, validate_token_resource=True) + with pytest.raises(ValidationError, match="validate_token_resource requires resource_server_url"): + AuthSettings(issuer_url=ISSUER, resource_server_url=None, validate_token_resource=True) + + +def test_leaving_validate_token_resource_unset_warns_when_a_resource_server_url_is_configured(): + """Unset behaves as False but says so: a resource server that has not chosen gets an + `MCPDeprecationWarning` pointing at its own `AuthSettings(...)` call (3.0 flips the default).""" + with pytest.warns(MCPDeprecationWarning, match="validate_token_resource") as record: + settings = AuthSettings(issuer_url=ISSUER, resource_server_url=RESOURCE) + assert settings.validate_token_resource is None + assert record[0].filename == __file__ + + +@pytest.mark.parametrize("kwargs", [{"validate_token_resource": False}, {"resource_server_url": None}]) +def test_an_explicit_choice_or_no_resource_server_url_does_not_warn(kwargs: dict[str, object]): + with warnings.catch_warnings(): + warnings.simplefilter("error") + AuthSettings.model_validate({"issuer_url": ISSUER, "resource_server_url": RESOURCE, **kwargs}) From 7bb486a10fa68681b262f063e36438cc45260f7d Mon Sep 17 00:00:00 2001 From: Max <224885523+maxisbey@users.noreply.github.com> Date: Sat, 5 Sep 2026 04:15:58 +0100 Subject: [PATCH 16/20] docs: stop presenting the in-memory client as the way to connect (#3443) --- docs/advanced/apps.md | 29 +++++- docs/advanced/extensions.md | 60 ++++++++---- docs/advanced/low-level-server.md | 18 ++-- docs/advanced/pagination.md | 12 ++- docs/client/caching.md | 20 +++- docs/client/callbacks.md | 4 +- docs/client/index.md | 46 +++++---- docs/client/oauth-clients.md | 2 +- docs/client/session-groups.md | 2 +- docs/client/transports.md | 33 +++---- docs/get-started/first-steps.md | 33 ++++--- docs/get-started/testing.md | 6 +- docs/handlers/multi-round-trip.md | 2 +- docs/handlers/progress.md | 29 +++--- docs/protocol-versions.md | 23 +++-- docs/run/asgi.md | 2 +- docs/run/authorization.md | 2 +- docs/run/legacy-clients.md | 18 +++- docs/troubleshooting.md | 30 ++++-- docs/whats-new.md | 14 ++- docs_src/apps/tutorial001.py | 11 +-- docs_src/apps/tutorial001_client.py | 20 ++++ docs_src/caching/tutorial002.py | 2 + docs_src/caching/tutorial003.py | 45 ++++----- docs_src/client/tutorial001.py | 58 +++++++++-- docs_src/client/tutorial001_client.py | 15 +++ docs_src/client/tutorial002.py | 17 ++-- docs_src/client/tutorial003.py | 26 ++--- docs_src/client/tutorial004.py | 23 ++--- docs_src/client/tutorial005.py | 17 ++-- docs_src/client/tutorial006.py | 30 ++---- docs_src/client/tutorial007.py | 39 ++++---- docs_src/extensions/tutorial003.py | 10 -- docs_src/extensions/tutorial003_client.py | 16 ++++ docs_src/extensions/tutorial004.py | 17 +--- docs_src/extensions/tutorial004_client.py | 35 +++++++ docs_src/extensions/tutorial006.py | 31 +----- docs_src/extensions/tutorial006_client.py | 40 ++++++++ docs_src/extensions/tutorial007.py | 18 +--- docs_src/extensions/tutorial007_client.py | 35 +++++++ docs_src/first_steps/tutorial001_client.py | 12 +++ docs_src/legacy_clients/tutorial001.py | 17 ---- docs_src/legacy_clients/tutorial001_client.py | 23 +++++ docs_src/lowlevel/tutorial001.py | 1 + docs_src/pagination/tutorial001.py | 1 + docs_src/pagination/tutorial002.py | 43 ++++----- docs_src/protocol_versions/tutorial001.py | 17 ++-- docs_src/protocol_versions/tutorial002.py | 17 ++-- docs_src/protocol_versions/tutorial003.py | 17 ++-- docs_src/protocol_versions/tutorial004.py | 19 ++-- examples/stories/bearer_auth/README.md | 2 +- examples/stories/identity_assertion/README.md | 2 +- examples/stories/pagination/README.md | 4 +- examples/stories/prompts/README.md | 5 +- examples/stories/reconnect/README.md | 2 +- examples/stories/resources/README.md | 5 +- examples/stories/schema_validators/README.md | 5 +- src/mcp/client/client.py | 11 +-- tests/docs_src/test_apps.py | 19 +--- tests/docs_src/test_caching.py | 10 +- tests/docs_src/test_client.py | 95 ++++++++++--------- tests/docs_src/test_extensions.py | 68 +++++++------ tests/docs_src/test_legacy_clients.py | 29 +++--- tests/docs_src/test_lowlevel.py | 8 ++ tests/docs_src/test_pagination.py | 30 +++--- tests/docs_src/test_progress.py | 2 +- tests/docs_src/test_protocol_versions.py | 45 +++++---- 67 files changed, 802 insertions(+), 597 deletions(-) create mode 100644 docs_src/apps/tutorial001_client.py create mode 100644 docs_src/client/tutorial001_client.py create mode 100644 docs_src/extensions/tutorial003_client.py create mode 100644 docs_src/extensions/tutorial004_client.py create mode 100644 docs_src/extensions/tutorial006_client.py create mode 100644 docs_src/extensions/tutorial007_client.py create mode 100644 docs_src/first_steps/tutorial001_client.py create mode 100644 docs_src/legacy_clients/tutorial001_client.py diff --git a/docs/advanced/apps.md b/docs/advanced/apps.md index a60c997b..3b6232ca 100644 --- a/docs/advanced/apps.md +++ b/docs/advanced/apps.md @@ -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 diff --git a/docs/advanced/extensions.md b/docs/advanced/extensions.md index de7937fc..b8372da3 100644 --- a/docs/advanced/extensions.md +++ b/docs/advanced/extensions.md @@ -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. diff --git a/docs/advanced/low-level-server.md b/docs/advanced/low-level-server.md index 575ecb8f..5d49846b 100644 --- a/docs/advanced/low-level-server.md +++ b/docs/advanced/low-level-server.md @@ -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)**. diff --git a/docs/advanced/pagination.md b/docs/advanced/pagination.md index 9f807a8e..1549f39d 100644 --- a/docs/advanced/pagination.md +++ b/docs/advanced/pagination.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. diff --git a/docs/client/caching.md b/docs/client/caching.md index 5d83cfa9..544fe96a 100644 --- a/docs/client/caching.md +++ b/docs/client/caching.md @@ -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. diff --git a/docs/client/callbacks.md b/docs/client/callbacks.md index 5f1dd194..4ebdd26d 100644 --- a/docs/client/callbacks.md +++ b/docs/client/callbacks.md @@ -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. diff --git a/docs/client/index.md b/docs/client/index.md index 767f9eb0..9433e703 100644 --- a/docs/client/index.md +++ b/docs/client/index.md @@ -6,13 +6,23 @@ It is one object with one lifecycle: construct it, enter `async with`, call meth ## Your first client -```python title="client.py" hl_lines="14-18" +A client needs a server to talk to. This Bookshop is the one every snippet on this page connects to. Save it as `server.py` and leave it running over HTTP: + +```python title="server.py" --8<-- "docs_src/client/tutorial001.py" ``` -The server at the top is only there so you have something to connect to. The client is the five highlighted lines. +```console +uv run mcp run server.py --transport streamable-http +``` -* `Client(mcp)` is given the **server object itself**. That is the in-memory transport: no subprocess, no port, no HTTP. It is how every example on this page, and every test you write, connects. +That serves it at `http://localhost:8000/mcp`. The client is its own program. Save it as `client.py` and run `python client.py` in a second terminal: + +```python title="client.py" hl_lines="7-11" +--8<-- "docs_src/client/tutorial001_client.py" +``` + +* `Client("http://localhost:8000/mcp")` is given a **URL**, so it connects over Streamable HTTP to the server you just started. * `async with` is the **lifecycle**. Entering it connects and negotiates; leaving it disconnects. There is no `connect()` / `close()` pair, and a `Client` cannot be reused after the block ends. * Inside the block the connection facts are already there as plain properties. @@ -20,10 +30,10 @@ The server at the top is only there so you have something to connect to. The cli `Client` takes one positional argument and resolves the transport from its type: -* An `MCPServer` (or low-level `Server`) instance: connected **in-process**. -* A URL string (`Client("http://localhost:8000/mcp")`): Streamable HTTP, the production path. -* A `StdioServerParameters`: the command to launch as a **subprocess**, spoken to over its stdin and stdout. +* A URL string (`Client("http://localhost:8000/mcp")`): Streamable HTTP, the transport you deploy behind. +* A `StdioServerParameters`: the command to launch as a local **subprocess**, spoken to over its stdin and stdout. * A **transport**: anything you can `async with ... as (read, write)`, such as `streamable_http_client(url, http_client=...)` around your own HTTP client. +* An `MCPServer` (or low-level `Server`) instance: connected **in-process**, with no subprocess and no port. That one is for tests, and **[Testing](../get-started/testing.md)** builds on it. Everything else on this page is identical across all four. Headers, subprocesses, timeouts, and the `Transport` protocol get their own page: **[Client transports](transports.md)**. @@ -44,11 +54,11 @@ You never picked a protocol version. By default the `Client` probes the server a ## Listing tools -```python title="client.py" hl_lines="15-20" +```python title="client.py" hl_lines="8-13" --8<-- "docs_src/client/tutorial002.py" ``` -`list_tools()` returns a `ListToolsResult`; the tools are in `.tools`. Each one is the complete definition a host would hand to a model: +`list_tools()` returns a `ListToolsResult`; the tools are in `.tools`. Each one is the complete definition a host would hand to a model. Here is the first: ```python tool.name # 'search_books' @@ -72,6 +82,8 @@ and `tool.input_schema` is the JSON Schema the server derived from the function' That schema is everything a UI needs to render an argument form, and everything a model needs to produce valid arguments. +The second tool, `lookup_book`, was registered without a `title=`, so its `tool.title` is `None`. + !!! tip `title` is optional, so a UI showing tools to a human has to pick: the `title` if there is one, the `name` if not. `from mcp.shared.metadata_utils import get_display_name` does exactly that, @@ -81,7 +93,7 @@ That schema is everything a UI needs to render an argument form, and everything `call_tool(name, arguments)` runs the tool and gives you back a `CallToolResult`. -```python title="client.py" hl_lines="27-34" +```python title="client.py" hl_lines="9-16" --8<-- "docs_src/client/tutorial003.py" ``` @@ -137,7 +149,7 @@ A tool that raises does **not** raise in your client. It comes back as an ordina The resource verbs come in pairs: two ways to list, one way to read. -```python title="client.py" hl_lines="22-31" +```python title="client.py" hl_lines="9-18" --8<-- "docs_src/client/tutorial004.py" ``` @@ -151,7 +163,7 @@ A client can also be told when a resource changes. On 2025-era connections that ## Prompts -```python title="client.py" hl_lines="15-20" +```python title="client.py" hl_lines="8-13" --8<-- "docs_src/client/tutorial005.py" ``` @@ -176,7 +188,7 @@ A host hands those messages straight to the model. That is the whole feature. A server with a completion handler can autocomplete prompt and resource-template arguments as the user types. -```python title="client.py" hl_lines="27-31" +```python title="client.py" hl_lines="9-13" --8<-- "docs_src/client/tutorial006.py" ``` @@ -189,21 +201,21 @@ The answer is in `result.completion.values`. Type `"p"` and the server comes bac Every `list_*` method takes a `cursor=` keyword and every result carries a `next_cursor`. When `next_cursor` is `None`, you have everything. -```python title="client.py" hl_lines="22-30" +```python title="client.py" hl_lines="7-15" --8<-- "docs_src/client/tutorial007.py" ``` -This loop is correct against every server. `MCPServer` returns everything in one page, so `next_cursor` is `None` and the loop runs once, which is why most code never writes it. Servers that genuinely page, and the rules cursors obey, are in **[Pagination](../advanced/pagination.md)**. +`list_all_tools` is correct against every server. `MCPServer` returns everything in one page, so `next_cursor` is `None` and the loop runs once, which is why most code never writes it. Servers that genuinely page, and the rules cursors obey, are in **[Pagination](../advanced/pagination.md)**. ## In tests -`Client(mcp)` with no process and no port is already a test harness for your server. +Every `client.py` on this page reached `server.py` over HTTP. In a test you skip the network and hand `Client` the server object itself: `from server import mcp`, then `Client(mcp)`. No process, no port, and every method above works the same. -There is one constructor flag built for that: `Client(mcp, raise_exceptions=True)`. It only has an effect on in-memory connections, and **[Testing](../get-started/testing.md)** is the page that explains it and builds the whole pattern around it. +There is one constructor flag built for that: `Client(mcp, raise_exceptions=True)`. It only has an effect on in-process connections, and **[Testing](../get-started/testing.md)** is the page that explains it and builds the whole pattern around it. ## Recap -* `Client(x)` connects in-memory to a server object, over Streamable HTTP to a URL string, and over anything else via a transport. +* `Client(x)` connects over Streamable HTTP to a URL string, launches a subprocess for a `StdioServerParameters`, enters a transport directly, and in tests takes the server object itself. * `async with` is the whole lifecycle. Inside it, `server_capabilities` and `protocol_version` are already populated; `server_info` and `instructions` are too when the server provides them. * `list_tools()` gives you each tool's `name`, `title`, `description` and `input_schema`. * `call_tool()` returns `content` for the model, `structured_content` for your code, and `is_error`. A raising tool is a result, not an exception. diff --git a/docs/client/oauth-clients.md b/docs/client/oauth-clients.md index 96ead3c7..54578439 100644 --- a/docs/client/oauth-clients.md +++ b/docs/client/oauth-clients.md @@ -89,7 +89,7 @@ You wrote none of it. Two keyword arguments remain (`client_metadata_url` and `v ### Try it -Most examples in these docs you can check with an in-memory `Client(server)`. Not this: the whole point of the flow is an HTTP `401`, and there is no HTTP between an in-memory client and its server. +The in-memory `Client(server)` your tests use is no help here: the whole point of the flow is an HTTP `401`, and there is no HTTP between an in-memory client and its server. The repository ships the live version. `examples/servers/simple-auth/` runs a standalone authorization server and a protected MCP server; `examples/clients/simple-auth-client/` is this page's client grown into a small CLI. Its README has the two commands: start the servers, run the client against them, and you watch the four steps go by. diff --git a/docs/client/session-groups.md b/docs/client/session-groups.md index 70ac02e8..de43e748 100644 --- a/docs/client/session-groups.md +++ b/docs/client/session-groups.md @@ -73,7 +73,7 @@ If you already hold a connected `ClientSession` (`Client.session` is one), hand ## Recap * `ClientSessionGroup` holds many server connections and merges their tools, resources, and prompts into one `dict` each. -* `connect_to_server(params)` per server. It takes transport parameters, never the server object or URL a `Client` takes. +* `connect_to_server(params)` per server. It takes transport parameters, never the URL or `Transport` a `Client` takes. * `group.call_tool(name, arguments)` routes to the owning server for you. * Names must be unique across the whole group; two servers with a `search` tool cannot coexist on their own. * `component_name_hook=` rewrites every registered name. The dict key changes, the wire name does not. diff --git a/docs/client/transports.md b/docs/client/transports.md index 1a494496..6d9d30f9 100644 --- a/docs/client/transports.md +++ b/docs/client/transports.md @@ -6,24 +6,9 @@ You never configure one separately. `Client` takes a single positional argument The *server* side of each (what `mcp.run()` does and what you deploy) is **[Running your server](../run/index.md)**. -## In memory - -Pass the server object itself: - -```python title="client.py" hl_lines="14" ---8<-- "docs_src/client_transports/tutorial001.py" -``` - -No subprocess, no port, no bytes on a wire. The client and the server are two objects in the same process, and the call still goes through the real protocol layer: `search_books` is listed, validated and invoked exactly as it would be over HTTP. - -That makes it two things at once: - -* **A test harness.** Every example in this documentation is exercised this way, and the **[Testing](../get-started/testing.md)** page builds the whole pattern around it. -* **An embedding API.** An application that constructs the server doesn't need a network hop to call its tools. - ## Streamable HTTP -Pass a URL string and you get **Streamable HTTP**, the transport you deploy behind: +Pass a URL string and you get **Streamable HTTP**, the transport you deploy behind and the one to reach for first: ```python title="client.py" hl_lines="5" --8<-- "docs_src/client_transports/tutorial002.py" @@ -121,6 +106,18 @@ The child's stderr goes to yours. To send it somewhere else, build the transport A server that needs an API key won't find it there. Pass it explicitly with `env=`; those variables are merged on top of the allow-list. That is what `BOOKSHOP_API_KEY` is doing above. +## In memory + +In a test there is nothing to deploy and nothing to launch. Pass the server object itself: + +```python hl_lines="14" +--8<-- "docs_src/client_transports/tutorial001.py" +``` + +No subprocess, no port, no bytes on a wire. The client and the server are two objects in the same process, and the call still goes through the real protocol layer: `search_books` is listed, validated and invoked exactly as it would be over HTTP. **[Testing](../get-started/testing.md)** builds the whole pattern around it. + +The same form doubles as an embedding API: an application that constructs the server itself can call its tools without a network hop. + ## SSE `sse_client(url)`, from `mcp.client.sse`, is the HTTP transport that Streamable HTTP superseded. Wrap it the same way, `Client(sse_client("http://localhost:8000/sse"))`, to talk to a server that still speaks it, and don't build anything new on it. @@ -129,16 +126,16 @@ The child's stderr goes to yours. To send it somewhere else, build the transport To `Client`, all of the above are the same thing. -A **transport** is any async context manager that yields a `(read, write)` pair of message streams: formally, the `Transport` protocol in `mcp.client`. `Client` resolves its argument by type: a server object connects in-process, a `str` becomes `streamable_http_client(url)`, a `StdioServerParameters` becomes `stdio_client(params)`, and anything else is entered as a transport directly. That last rule is why `stdio_client(...)`, `streamable_http_client(...)` and `sse_client(...)` all drop into the same slot, and why you can write your own. +A **transport** is any async context manager that yields a `(read, write)` pair of message streams: formally, the `Transport` protocol in `mcp.client`. `Client` resolves its argument by type: a `str` becomes `streamable_http_client(url)`, a `StdioServerParameters` becomes `stdio_client(params)`, a server object connects in-process, and anything else is entered as a transport directly. That last rule is why `stdio_client(...)`, `streamable_http_client(...)` and `sse_client(...)` all drop into the same slot, and why you can write your own. ## Recap -* `Client(mcp)` (the server object) connects in memory. Use it for tests and for embedding. * `Client("http://.../mcp")` (a URL) connects over Streamable HTTP, the production transport. * Headers, auth, proxies and timeouts belong on an `httpx2.AsyncClient` you pass to `streamable_http_client(url, http_client=...)`. There is no `headers=` keyword. * Redirects are followed only within the URL's own origin (a trailing-slash `307`/`308`), plus `http`→`https` on the same host. Anything else fails with `Redirect to … not followed`; configure the final URL. * stdio is `Client(StdioServerParameters(...))`. Wrap it in `stdio_client(...)` yourself only to redirect the child's stderr. * The subprocess gets an allow-listed environment, not yours; `env=` adds to it. +* `Client(mcp)` (the server object) connects in memory. Use it in tests, or to embed a server in the application that built it. * A transport is anything you can `async with x as (read, write)`. `Client` hands anything that isn't a server object, a URL or `StdioServerParameters` straight to that protocol. * Constructing a `Client` picks the transport. `async with` opens it. diff --git a/docs/get-started/first-steps.md b/docs/get-started/first-steps.md index ad2f4c63..a5d8b4ff 100644 --- a/docs/get-started/first-steps.md +++ b/docs/get-started/first-steps.md @@ -12,7 +12,7 @@ Three words you'll see on every page from here on: * A **client** lives inside the host and speaks MCP. The host runs one client per server it's connected to. * A **server** is what you build with this SDK. It exposes things to clients. It never talks to the model directly. -You write the server. Hosts are someone else's product. The SDK also gives you a `Client`. You'll use it to test your servers, and it shows up later on this page. +You write the server. Hosts are someone else's product. The SDK also gives you a `Client`, the same class a host would use to reach a server by URL or launch it as a subprocess. It shows up later on this page, and it is also how you'll test your servers. ## The three primitives @@ -78,22 +78,20 @@ You saw three tabs in the Inspector. How did it know there were three? When a client connects, the server declares its **capabilities**: which families of requests it will answer. The client uses that declaration to decide what to even ask for. You never wrote it; `MCPServer` declares it for you. -Look at it yourself. The SDK's `Client` accepts the server object directly and connects to it **in memory** (no subprocess, no port): +Look at it yourself. Leave `server.py` running over HTTP in one terminal: -```python -import asyncio +```console +uv run mcp run server.py --transport streamable-http +``` -from mcp import Client +and point a client at it from another: -from server import mcp +```python title="client.py" hl_lines="7-8" +--8<-- "docs_src/first_steps/tutorial001_client.py" +``` - -async def main() -> None: - async with Client(mcp) as client: - print(client.server_capabilities.model_dump(exclude_none=True)) - - -asyncio.run(main()) +```console +python client.py ``` ```text @@ -113,8 +111,9 @@ That dictionary is your server's declared **capabilities**. It's the first thing Notice what isn't there. `completions` (argument autocomplete for resource templates and prompts) needs a handler you write, this server doesn't have one, so the capability is absent and a well-behaved client won't ask. That's the rule for everything optional: register the thing and the capability appears; **[Completions](../servers/completions.md)** proves it. !!! info - `Client(mcp)` is the same in-memory client every example in these docs is tested with, and - it's how you'll test yours. It gets a whole page: **[Testing](testing.md)**. + That `client.py` is a complete MCP client, and **[The Client](../client/index.md)** is its page. + In a test you skip the terminal and the port and hand `Client` the server object itself, + `Client(mcp)`. That gets a whole page too: **[Testing](testing.md)**. ## What you did not write @@ -123,7 +122,7 @@ Look back over this page. You wrote three small Python functions. You did **not* * A JSON Schema. `a: int, b: int` *is* the schema for `add`. * A request handler. `tools/list`, `resources/read`, `prompts/get`: all served for you. * A capability declaration. `MCPServer` made it for you. -* A line of protocol. The version negotiation, the JSON-RPC framing, the capability exchange: all of it happened inside `mcp dev` and `Client(mcp)`, and you never saw it. +* A line of protocol. The version negotiation, the JSON-RPC framing, the capability exchange: all of it happened inside `mcp dev` and `client.py`, and you never saw it. That ratio is the whole point of the SDK. @@ -134,6 +133,6 @@ That ratio is the whole point of the SDK. * One decorator per primitive: `@mcp.tool()`, `@mcp.resource(uri)`, `@mcp.prompt()`. Name, description, and schema come from the function. * A URI with a `{param}` makes a resource **template**, listed separately from concrete resources. * The server's **capabilities** are declared for you, and a client only asks for what a server declares. -* `Client(mcp)` connects to the server object in memory: your test harness from day one. +* `Client("http://localhost:8000/mcp")` talks to your running server. Hand it the server object instead, `Client(mcp)`, and it is your test harness from day one. Next up is **[Connect to a real host](real-host.md)**: this server inside Claude Desktop or an IDE, for real. Then **[Testing](testing.md)**: one page, one in-memory client, and you're never guessing whether it works. After that, each primitive gets its own page, starting with the one the model drives: **[Tools](../servers/tools.md)**. diff --git a/docs/get-started/testing.md b/docs/get-started/testing.md index e45d13b4..98c67173 100644 --- a/docs/get-started/testing.md +++ b/docs/get-started/testing.md @@ -1,8 +1,8 @@ # Testing -The Python SDK ships a `Client` class with an **in-memory transport**: pass it your server object and it connects to it directly. +The SDK's `Client` class, the same one that connects to a URL or launches a subprocess, also connects **in memory**: pass it your server object and it talks to it directly. -No subprocess. No port. No transport at all. It's the same idea as FastAPI's `TestClient`. +No subprocess. No port. Nothing on a wire. It's the same idea as FastAPI's `TestClient`. ## Basic usage @@ -91,7 +91,7 @@ instead of the sanitised one. Leave it on in tests. It has no meaning in production code. -## In-process by default +## Era-neutral by default !!! note `Client(mcp)` connects in-process and is **era-neutral** by default: it probes the server and diff --git a/docs/handlers/multi-round-trip.md b/docs/handlers/multi-round-trip.md index 1d5b9f52..c76069db 100644 --- a/docs/handlers/multi-round-trip.md +++ b/docs/handlers/multi-round-trip.md @@ -159,7 +159,7 @@ The low-level `Server` is the no-batteries tier: unlike `MCPServer`, nothing is ## A 2026-07-28 result -`InputRequiredResult` only exists at protocol version **2026-07-28**. The in-memory `Client(server)` negotiates it for you; over the wire, `mode="auto"` discovers it. After connecting, `client.protocol_version` tells you what you got. +`InputRequiredResult` only exists at protocol version **2026-07-28**. `Client`'s default `mode="auto"` discovers it on any connection. After connecting, `client.protocol_version` tells you what you got. !!! warning A pre-2026 session has nowhere to put an `InputRequiredResult`. Return one from your handler on a diff --git a/docs/handlers/progress.md b/docs/handlers/progress.md index 57bbb59e..d48b4c6a 100644 --- a/docs/handlers/progress.md +++ b/docs/handlers/progress.md @@ -24,19 +24,17 @@ Three arguments, and you decide what they mean: The client opts in **per call**, by passing `progress_callback=` to `call_tool`: -```python title="client.py" hl_lines="7 16" +```python title="client.py" hl_lines="5 14" import anyio from mcp import Client -from server import mcp - async def show(progress: float, total: float | None, message: str | None) -> None: print(f"{message} ({progress}/{total})") async def main() -> None: - async with Client(mcp) as client: + async with Client("http://localhost:8000/mcp") as client: result = await client.call_tool( "import_catalog", {"urls": ["https://example.com/a.json", "https://example.com/b.json"]}, @@ -51,28 +49,31 @@ anyio.run(main) The callback is an `async` function taking exactly what the server reported: `progress`, `total`, `message`. !!! info - `Client(mcp)` connects straight to the server object, in memory, the same client the **[Testing](../get-started/testing.md)** - page is built on. `progress_callback` is the same parameter whatever transport the `Client` - uses; the *timing* you are about to see is the in-memory connection's. It runs your callback - inline, so every report lands before `call_tool` returns. Over a real transport the - notifications race the result, and a slow callback can still be running after `call_tool` has - returned. + `progress_callback` is the same parameter whatever you handed `Client`: a URL as here, a + `StdioServerParameters`, or the server object in a test. Mind the timing over a real + transport, though. Each notification is delivered on its own, beside the response, so a slow + callback can still be running after `call_tool` has returned. Only the in-process test + connection runs the callback inline and guarantees every report lands first. ### Try it -Put `client.py` next to `server.py` and run it: +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 -Imported https://example.com/a.json (1/2) -Imported https://example.com/b.json (2/2) +Imported https://example.com/a.json (1.0/2.0) +Imported https://example.com/b.json (2.0/2.0) {'result': 'Imported 2 records.'} ``` -Every `await ctx.report_progress(...)` on the server became one call to `show` on the client, in order, and both lines printed **before** `call_tool` returned. Progress is not bundled into the result; it streams while the tool is still working. +Every `await ctx.report_progress(...)` on the server became one call to `show` on the client, in order. Progress is not bundled into the result. It streams while the tool is still working. !!! warning `progress_callback` belongs to the **call**, not the `Client`. There is no constructor argument diff --git a/docs/protocol-versions.md b/docs/protocol-versions.md index 9ef19a7c..ab40f24a 100644 --- a/docs/protocol-versions.md +++ b/docs/protocol-versions.md @@ -6,9 +6,17 @@ Servers released before 2026-07-28 open every connection with the **`initialize` You almost never have to care, because `Client` negotiates for you. This page is about the one constructor argument that controls it, `mode=`, and the three times you change it. +Every snippet on this page is a `client.py` that talks to the Bookshop `server.py` from **[The Client](client/index.md)**. Start that server in one terminal: + +```console +uv run mcp run server.py --transport streamable-http +``` + +Then run each snippet in a second terminal with `python client.py`. + ## `mode="auto"` -```python title="client.py" hl_lines="14-15" +```python title="client.py" hl_lines="7-8" --8<-- "docs_src/protocol_versions/tutorial001.py" ``` @@ -26,13 +34,14 @@ Either way you come out connected, and `client.protocol_version` tells you which That is the whole feature. One `Client`, any era of server, no branching in your code. !!! info - `MCPServer` answers `server/discover` on every transport — in-memory, stdio, streamable - HTTP — so against your own server `auto` always lands on `2026-07-28`. The fallback only - ever fires against a real pre-2026 server, which is exactly when you want it to. + `MCPServer` answers `server/discover` on every transport — Streamable HTTP, stdio, and the + in-process connection your tests use — so against your own server `auto` always lands on + `2026-07-28`. The fallback only ever fires against a real pre-2026 server, which is exactly + when you want it to. ## `mode="legacy"` -```python title="client.py" hl_lines="14" +```python title="client.py" hl_lines="7" --8<-- "docs_src/protocol_versions/tutorial002.py" ``` @@ -56,7 +65,7 @@ At 2026-07-28 it is gone. The server *returns* its questions and you retry the c `mode` also accepts a modern protocol version string. Today that set is exactly `["2026-07-28"]`. -```python title="client.py" hl_lines="14" +```python title="client.py" hl_lines="7" --8<-- "docs_src/protocol_versions/tutorial003.py" ``` @@ -89,7 +98,7 @@ The probe is cheap, but it is still a round trip you pay on every reconnect, and So keep it. After an `auto` connection, `client.session.discover_result` holds the exact `DiscoverResult` the server sent: its `supported_versions`, its `capabilities`, its `instructions`, and the identity the server stamped into the result's `_meta`. Hand it back as `prior_discover=` the next time: -```python title="client.py" hl_lines="15 17" +```python title="client.py" hl_lines="8 10" --8<-- "docs_src/protocol_versions/tutorial004.py" ``` diff --git a/docs/run/asgi.md b/docs/run/asgi.md index dd116be3..78e97888 100644 --- a/docs/run/asgi.md +++ b/docs/run/asgi.md @@ -137,4 +137,4 @@ A browser-based client needs two permissions from you: to **send** its MCP reque * Browser clients need CORS: `allow_headers` for the `Mcp-*` request headers, `expose_headers=["Mcp-Session-Id"]` for the response. * `@mcp.custom_route()` adds plain, unauthenticated HTTP endpoints next to `/mcp`. -Once the server is reachable at a real URL, **[The Client](../client/index.md)** connects to it with that URL instead of a server object. +Once the server is reachable at a real URL, **[The Client](../client/index.md)** connects to it with that URL. diff --git a/docs/run/authorization.md b/docs/run/authorization.md index dfcbbc9a..fefd0ed3 100644 --- a/docs/run/authorization.md +++ b/docs/run/authorization.md @@ -124,6 +124,6 @@ An authorization server can also accept an enterprise identity provider's signed * `token_verifier=` and `auth=AuthSettings(issuer_url=..., resource_server_url=..., required_scopes=[...])` always travel together. * The SDK publishes [RFC 9728](https://datatracker.ietf.org/doc/html/rfc9728) Protected Resource Metadata at `/.well-known/oauth-protected-resource/...` and answers unauthenticated requests with a 401 whose `WWW-Authenticate` header points at it. That is the entire discovery story. * `get_access_token()` in any handler is who's calling. -* Authorization is an HTTP concern. `stdio` and the in-memory client never see it. +* Authorization is an HTTP concern. `stdio` and the in-memory test client never see it. The client half (discovering your authorization server and fetching the token for you) is **[OAuth clients](../client/oauth-clients.md)**. And a client that *asserts* an identity instead of asking a user for one is **[Identity assertion](../client/identity-assertion.md)**. diff --git a/docs/run/legacy-clients.md b/docs/run/legacy-clients.md index 699632ad..15809fd6 100644 --- a/docs/run/legacy-clients.md +++ b/docs/run/legacy-clients.md @@ -16,15 +16,25 @@ So a legacy client is not something you build *for*. It is something that connec ## One handler, both eras -Here is a tool that has to ask the user something, and both eras of client calling it: +Here is a tool that has to ask the user something: -```python title="server.py" hl_lines="24 37-38" +```python title="server.py" hl_lines="21" --8<-- "docs_src/legacy_clients/tutorial001.py" ``` `reserve` needs one thing the model didn't supply: how many copies. `Annotated[..., Resolve(ask_quantity)]` is how a tool declares that (**[Dependencies](../handlers/dependencies.md)** is that whole story). Nothing in `reserve` names a version, checks a capability, or branches. -The two clients are open **at the same time**, on the same `mcp` object. `mode="legacy"` runs the `initialize` handshake: the exact connection a pre-2026 client opens. The other one takes the default and lands on `2026-07-28`. +Serve it over HTTP, and here are both eras of client calling it: + +```console +uv run mcp run server.py --transport streamable-http +``` + +```python title="client.py" hl_lines="14-15" +--8<-- "docs_src/legacy_clients/tutorial001_client.py" +``` + +The two clients are open **at the same time**, against the same running server. `mode="legacy"` runs the `initialize` handshake: the exact connection a pre-2026 client opens. The other one takes the default and lands on `2026-07-28`. Run `python client.py` from a second terminal: ```text 2025-11-25 {'result': "Reserved 2 of 'Dune'."} @@ -115,7 +125,7 @@ Two things about it matter more than what it does. !!! check Do the wrong thing. `reserve` is the exact tool that just served both clients. Deploy it with - `stateless_http=True`, connect the same two clients over HTTP, and call it from each. + `stateless_http=True`, connect the same two clients, and call it from each. The modern client still gets `Reserved 2 of 'Dune'.` The modern leg didn't change. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 838c92ca..bb7dfda4 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -8,6 +8,12 @@ Several entries run against this one server. One tool and one templated resource --8<-- "docs_src/troubleshooting/tutorial001.py" ``` +Those entries reach it at `http://localhost:8000/mcp`, so leave it running over HTTP: + +```console +uv run mcp run server.py --transport streamable-http +``` + The errors this page quotes are real: the SDK's own test suite reproduces every one of them. ## `ExceptionGroup: unhandled errors in a TaskGroup (1 sub-exception)` @@ -18,7 +24,7 @@ This is not an MCP error. It is anyio noise, and your real error is the **last l ```python async def main() -> None: - async with Client(mcp) as client: + async with Client("http://localhost:8000/mcp") as client: await client.read_resource("weather://Atlantis") ``` @@ -44,7 +50,7 @@ Two things to do with that: ```python async def main() -> None: - async with Client(mcp) as client: + async with Client("http://localhost:8000/mcp") as client: try: await client.read_resource("weather://Atlantis") except MCPError as e: @@ -62,7 +68,7 @@ async def main() -> None: ```python async def main() -> None: - client = Client(mcp) + client = Client("http://localhost:8000/mcp") tools = await client.list_tools() # RuntimeError ``` @@ -70,7 +76,7 @@ Enter it. `__aenter__` is the connection: ```python async def main() -> None: - async with Client(mcp) as client: + async with Client("http://localhost:8000/mcp") as client: tools = await client.list_tools() ``` @@ -270,7 +276,13 @@ One thing does **not** produce this error, despite being a request the modern pr Your server wants to ask the user something, and this client never said it can be asked. -An elicitation resolver refuses up front when the connected client did not declare form elicitation, and `e.error.data` names exactly what is missing: +This Bistro asks before it books, through a resolver: + +```python title="server.py" hl_lines="15-17 21" +--8<-- "docs_src/troubleshooting/tutorial007.py" +``` + +Serve it in place of the Weather server and call `book_table` from a client that passed no `elicitation_callback`. The resolver refuses up front, because the connected client never declared form elicitation, and `e.error.data` names exactly what is missing: ```json { @@ -284,7 +296,7 @@ Pass `elicitation_callback=` to `Client(...)`. Registering the callback *is* the ```python async def main() -> None: - async with Client(mcp, elicitation_callback=handle_elicitation) as client: + async with Client("http://localhost:8000/mcp", elicitation_callback=handle_elicitation) as client: result = await client.call_tool("book_table", {"date": "Friday"}) ``` @@ -309,14 +321,14 @@ You see this one from `ctx.elicit()` on a legacy connection, and on any connecti Your handler tried to reach the client mid-request, on a connection whose call has no channel that can carry a request from the server. There are three server configurations that put a call there. -**A `2026-07-28` connection: any transport, always.** The modern protocol has no server-initiated requests at all, so the server refuses before anything is sent. `ctx.elicit()` inside a tool is the classic way to meet this (on the very first in-memory test, since `Client(server)` negotiates `2026-07-28` without being asked), and passing `elicitation_callback=` changes nothing, because no request ever reaches the client for it to answer: +**A `2026-07-28` connection: any transport, always.** The modern protocol has no server-initiated requests at all, so the server refuses before anything is sent. `ctx.elicit()` inside a tool is the classic way to meet this, usually in that tool's very first in-memory **[test](get-started/testing.md)**, since `Client(mcp)` negotiates `2026-07-28` without being asked. Passing `elicitation_callback=` changes nothing, because no request ever reaches the client for it to answer: ```python title="server.py" hl_lines="16" --8<-- "docs_src/troubleshooting/tutorial006.py" ``` ```python -async def main() -> None: +async def test_book_table() -> None: async with Client(mcp) as client: await client.call_tool("book_table", {"date": "Friday"}) ``` @@ -358,7 +370,7 @@ The server could not verify the `requestState` token your client echoed back, so ```python async def main() -> None: - async with Client(mcp) as client: + async with Client("http://localhost:8000/mcp") as client: await client.call_tool("forecast", {"city": "London"}, request_state="round-1-from-worker-a") ``` diff --git a/docs/whats-new.md b/docs/whats-new.md index 068efb26..6627f794 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -37,11 +37,11 @@ Not everything a tool needs should come from the model. New in v2, a tool parame v1 handed you three nested layers: a transport context manager yielding raw streams, a `ClientSession` wrapped around them, and a hand-called `await session.initialize()`. v2 has one object: -```python title="client.py" hl_lines="14-18" ---8<-- "docs_src/client/tutorial001.py" +```python title="client.py" hl_lines="7-11" +--8<-- "docs_src/client/tutorial001_client.py" ``` -`Client` takes a server object (in memory, no transport: the testing story), a URL (Streamable HTTP), a `StdioServerParameters` (a stdio subprocess), or any other transport context manager such as `sse_client(...)`. Entering `async with` connects and negotiates the protocol version, whichever era the server speaks; `client.server_capabilities` and `client.protocol_version` are simply there afterwards, and `client.server_info` is too when the server identifies itself (it is `Implementation | None` now, since 2026-era identity is optional). The sampling and elicitation callbacks you registered in v1 still work (their bodies see the same snake_case attribute rename as everything else on this page), they now also answer the 2026-style requests-inside-results (below), and they run concurrently instead of one at a time. `ClientSession` is still underneath for anyone who wants the low-level surface, and `client.session` hands it to you; it moved too (it runs on the new dispatcher engine, and some of its own signatures changed), so read the **[Migration Guide](migration.md#clientsession-now-runs-on-jsonrpcdispatcher-basesession-removed)** before you drop down. +`Client` takes a URL (Streamable HTTP), a `StdioServerParameters` (a stdio subprocess), any other transport context manager such as `sse_client(...)`, or, in tests, the server object itself (in memory, no transport). Entering `async with` connects and negotiates the protocol version, whichever era the server speaks; `client.server_capabilities` and `client.protocol_version` are simply there afterwards, and `client.server_info` is too when the server identifies itself (it is `Implementation | None` now, since 2026-era identity is optional). The sampling and elicitation callbacks you registered in v1 still work (their bodies see the same snake_case attribute rename as everything else on this page), they now also answer the 2026-style requests-inside-results (below), and they run concurrently instead of one at a time. `ClientSession` is still underneath for anyone who wants the low-level surface, and `client.session` hands it to you; it moved too (it runs on the new dispatcher engine, and some of its own signatures changed), so read the **[Migration Guide](migration.md#clientsession-now-runs-on-jsonrpcdispatcher-basesession-removed)** before you drop down. **[The Client](client/index.md)** introduces it, **[Client transports](client/transports.md)** covers the four connection forms, **[Client callbacks](client/callbacks.md)** covers the callbacks themselves, and **[Testing](get-started/testing.md)** shows the in-memory pattern that replaces v1's `create_connected_server_and_client_session()` helper. @@ -166,11 +166,15 @@ Every server-initiated request is gone at 2026-07-28: push elicitation, sampling The replacement turns the call around. A tool that needs something from the user *returns* the question (`InputRequiredResult`), the client answers it with the same callbacks it always had, and the call is retried with the answers attached. `Client` drives that loop for you. On the server you rarely build the result yourself, because a **[dependency](handlers/dependencies.md)** does it: annotate a parameter with `Resolve(ask_quantity)`, where `ask_quantity` is an ordinary function you write, and the SDK asks over whichever mechanism the connection supports, a live elicitation request on a legacy session or a multi-round-trip on 2026. One tool body, both eras: -```python title="dual_era.py" hl_lines="24 37-38" +```python title="server.py" hl_lines="21" --8<-- "docs_src/legacy_clients/tutorial001.py" ``` -That file is the pitch in one place: one server, one `Resolve`-backed tool, and a legacy client plus a modern client both getting their answer, in memory. **[Multi-round-trip requests](handlers/multi-round-trip.md)** explains the mechanism (including `request_state`, which the SDK seals and verifies for you); **[Elicitation](handlers/elicitation.md)** covers the asking. +```python title="client.py" hl_lines="14-15" +--8<-- "docs_src/legacy_clients/tutorial001_client.py" +``` + +Those two files are the whole pitch: one server, one `Resolve`-backed tool, and a legacy client plus a modern client both getting their answer from the same running server (**[Serving legacy clients](run/legacy-clients.md)** walks through them). **[Multi-round-trip requests](handlers/multi-round-trip.md)** explains the mechanism (including `request_state`, which the SDK seals and verifies for you); **[Elicitation](handlers/elicitation.md)** covers the asking. !!! warning "This is the one place a ported v1 server changes behavior" Your own tests hit it first: `Client(mcp)` negotiates 2026-07-28 against your v2 server by diff --git a/docs_src/apps/tutorial001.py b/docs_src/apps/tutorial001.py index 27d9eded..6cbefdff 100644 --- a/docs_src/apps/tutorial001.py +++ b/docs_src/apps/tutorial001.py @@ -1,6 +1,4 @@ -from mcp import Client -from mcp.client import advertise -from mcp.server.apps import APP_MIME_TYPE, EXTENSION_ID, Apps, client_supports_apps +from mcp.server.apps import Apps, client_supports_apps from mcp.server.mcpserver import MCPServer from mcp.server.mcpserver.context import Context @@ -30,10 +28,3 @@ def get_time(ctx: Context) -> str: apps.add_html_resource("ui://clock/app.html", CLOCK_HTML, title="Clock") mcp = MCPServer("clock", extensions=[apps]) - - -async def main() -> None: - async with Client(mcp, extensions=[advertise(EXTENSION_ID, {"mimeTypes": [APP_MIME_TYPE]})]) as client: - result = await client.call_tool("get_time", {}) - print(result.content) - # [TextContent(text='2026-06-26T12:00:00Z')] diff --git a/docs_src/apps/tutorial001_client.py b/docs_src/apps/tutorial001_client.py new file mode 100644 index 00000000..dae52b4f --- /dev/null +++ b/docs_src/apps/tutorial001_client.py @@ -0,0 +1,20 @@ +import anyio + +from mcp import Client +from mcp.client import advertise +from mcp.server.apps import APP_MIME_TYPE, EXTENSION_ID +from mcp.types import TextContent + +APPS_SUPPORT = advertise(EXTENSION_ID, {"mimeTypes": [APP_MIME_TYPE]}) + + +async def main() -> None: + async with Client("http://localhost:8000/mcp", extensions=[APPS_SUPPORT]) as client: + result = await client.call_tool("get_time", {}) + for block in result.content: + if isinstance(block, TextContent): + print(block.text) + + +if __name__ == "__main__": + anyio.run(main) diff --git a/docs_src/caching/tutorial002.py b/docs_src/caching/tutorial002.py index e1722e81..acd95f2d 100644 --- a/docs_src/caching/tutorial002.py +++ b/docs_src/caching/tutorial002.py @@ -7,6 +7,7 @@ TOOLS = [Tool(name="forecast", input_schema={"type": "object"})] async def list_tools(ctx: ServerRequestContext[Any], params: PaginatedRequestParams | None) -> ListToolsResult: + print("tools/list served") return ListToolsResult(tools=TOOLS, ttl_ms=1_000) @@ -15,3 +16,4 @@ server = Server( on_list_tools=list_tools, cache_hints={"tools/list": CacheHint(ttl_ms=60_000, scope="public")}, ) +app = server.streamable_http_app() diff --git a/docs_src/caching/tutorial003.py b/docs_src/caching/tutorial003.py index 9ff3c361..f2495bab 100644 --- a/docs_src/caching/tutorial003.py +++ b/docs_src/caching/tutorial003.py @@ -1,39 +1,34 @@ from dataclasses import dataclass -from typing import Any + +import anyio from mcp import Client from mcp.client import CacheConfig -from mcp.server import CacheHint, Server, ServerRequestContext -from mcp.types import ListToolsResult, PaginatedRequestParams, Tool +from mcp.types import ListToolsResult @dataclass -class DemoState: - fetches: int = 0 - now: float = 1_000_000.0 +class Clock: + now: float = 0.0 -state = DemoState() +clock = Clock() # advanced by hand below, so the TTL runs out without sleeping -async def list_tools(ctx: ServerRequestContext[Any], params: PaginatedRequestParams | None) -> ListToolsResult: - state.fetches += 1 - return ListToolsResult(tools=[Tool(name="forecast", input_schema={"type": "object"})]) - - -server = Server( - "Weather", - on_list_tools=list_tools, - cache_hints={"tools/list": CacheHint(ttl_ms=60_000, scope="public")}, -) +async def run(client: Client) -> ListToolsResult: + tools = await client.list_tools() # fetch 1 + await client.list_tools() # still fresh: served from the cache + clock.now += 2 + await client.list_tools() # past the one-second TTL: fetch 2 + await client.list_tools(cache_mode="refresh") # skip the cache read: fetch 3 + return tools async def main() -> None: - start = state.fetches - async with Client(server, cache=CacheConfig(clock=lambda: state.now)) as client: - await client.list_tools() # fetch 1 - await client.list_tools() # fresh for 60s: served from the cache - state.now += 60.0 - await client.list_tools() # the TTL ran out: fetch 2 - await client.list_tools(cache_mode="refresh") # skip the cache read: fetch 3 - print(f"4 calls, {state.fetches - start} fetches") + async with Client("http://localhost:8000/mcp", cache=CacheConfig(clock=lambda: clock.now)) as client: + tools = await run(client) + print(tools.ttl_ms, tools.cache_scope) + + +if __name__ == "__main__": + anyio.run(main) diff --git a/docs_src/client/tutorial001.py b/docs_src/client/tutorial001.py index b020926d..e7645509 100644 --- a/docs_src/client/tutorial001.py +++ b/docs_src/client/tutorial001.py @@ -1,18 +1,56 @@ -from mcp import Client +from pydantic import BaseModel + from mcp.server import MCPServer +from mcp.server.mcpserver.exceptions import ToolError +from mcp.types import Completion, CompletionArgument, CompletionContext, PromptReference, ResourceTemplateReference mcp = MCPServer("Bookshop", instructions="Search the catalog before recommending a book.") +GENRES = ["fiction", "non-fiction", "poetry"] + + +class Book(BaseModel): + title: str + author: str + year: int + + +@mcp.tool(title="Search the catalog") +def search_books(query: str, limit: int = 10) -> str: + """Search the catalog by title or author.""" + return f"Found 3 books matching {query!r} (showing up to {limit})." + @mcp.tool() -def search_books(query: str) -> str: - """Search the catalog by title or author.""" - return f"Found 3 books matching {query!r}." +def lookup_book(title: str) -> Book: + """Look up a book by its exact title.""" + if title != "Dune": + raise ToolError(f"No book titled {title!r} in the catalog.") + return Book(title="Dune", author="Frank Herbert", year=1965) -async def main() -> None: - async with Client(mcp) as client: - print(client.server_info) - print(client.server_capabilities) - print(client.protocol_version) - print(client.instructions) +@mcp.resource("catalog://genres") +def genres() -> list[str]: + """The genres the catalog is organised by.""" + return GENRES + + +@mcp.resource("catalog://genres/{genre}") +def books_in_genre(genre: str) -> str: + """Every title we stock in one genre.""" + return f"3 books filed under {genre}." + + +@mcp.prompt(title="Recommend a book") +def recommend(genre: str) -> str: + """Ask for a recommendation in a genre.""" + return f"Recommend one {genre} book from the catalog and say why." + + +@mcp.completion() +async def complete_genre( + ref: PromptReference | ResourceTemplateReference, + argument: CompletionArgument, + context: CompletionContext | None, +) -> Completion | None: + return Completion(values=[genre for genre in GENRES if genre.startswith(argument.value)]) diff --git a/docs_src/client/tutorial001_client.py b/docs_src/client/tutorial001_client.py new file mode 100644 index 00000000..faf06506 --- /dev/null +++ b/docs_src/client/tutorial001_client.py @@ -0,0 +1,15 @@ +import anyio + +from mcp import Client + + +async def main() -> None: + async with Client("http://localhost:8000/mcp") as client: + print(client.server_info) + print(client.server_capabilities) + print(client.protocol_version) + print(client.instructions) + + +if __name__ == "__main__": + anyio.run(main) diff --git a/docs_src/client/tutorial002.py b/docs_src/client/tutorial002.py index a3e379ab..68fc7a6b 100644 --- a/docs_src/client/tutorial002.py +++ b/docs_src/client/tutorial002.py @@ -1,20 +1,17 @@ +import anyio + from mcp import Client -from mcp.server import MCPServer - -mcp = MCPServer("Bookshop") - - -@mcp.tool(title="Search the catalog") -def search_books(query: str, limit: int = 10) -> str: - """Search the catalog by title or author.""" - return f"Found 3 books matching {query!r} (showing up to {limit})." async def main() -> None: - async with Client(mcp) as client: + async with Client("http://localhost:8000/mcp") as client: result = await client.list_tools() for tool in result.tools: print(tool.name) print(tool.title) print(tool.description) print(tool.input_schema) + + +if __name__ == "__main__": + anyio.run(main) diff --git a/docs_src/client/tutorial003.py b/docs_src/client/tutorial003.py index 0831f5f7..f104205a 100644 --- a/docs_src/client/tutorial003.py +++ b/docs_src/client/tutorial003.py @@ -1,29 +1,11 @@ -from pydantic import BaseModel +import anyio from mcp import Client -from mcp.server import MCPServer -from mcp.server.mcpserver.exceptions import ToolError from mcp.types import TextContent -mcp = MCPServer("Bookshop") - - -class Book(BaseModel): - title: str - author: str - year: int - - -@mcp.tool() -def lookup_book(title: str) -> Book: - """Look up a book by its exact title.""" - if title != "Dune": - raise ToolError(f"No book titled {title!r} in the catalog.") - return Book(title="Dune", author="Frank Herbert", year=1965) - async def main() -> None: - async with Client(mcp) as client: + async with Client("http://localhost:8000/mcp") as client: result = await client.call_tool("lookup_book", {"title": "Dune"}) for block in result.content: @@ -32,3 +14,7 @@ async def main() -> None: print(result.structured_content) print(result.is_error) + + +if __name__ == "__main__": + anyio.run(main) diff --git a/docs_src/client/tutorial004.py b/docs_src/client/tutorial004.py index b0d62a77..35acc3d0 100644 --- a/docs_src/client/tutorial004.py +++ b/docs_src/client/tutorial004.py @@ -1,24 +1,11 @@ +import anyio + from mcp import Client -from mcp.server import MCPServer from mcp.types import TextResourceContents -mcp = MCPServer("Bookshop") - - -@mcp.resource("catalog://genres") -def genres() -> list[str]: - """The genres the catalog is organised by.""" - return ["fiction", "non-fiction", "poetry"] - - -@mcp.resource("catalog://genres/{genre}") -def books_in_genre(genre: str) -> str: - """Every title we stock in one genre.""" - return f"3 books filed under {genre}." - async def main() -> None: - async with Client(mcp) as client: + async with Client("http://localhost:8000/mcp") as client: listed = await client.list_resources() print([resource.uri for resource in listed.resources]) @@ -29,3 +16,7 @@ async def main() -> None: for contents in result.contents: if isinstance(contents, TextResourceContents): print(contents.text) + + +if __name__ == "__main__": + anyio.run(main) diff --git a/docs_src/client/tutorial005.py b/docs_src/client/tutorial005.py index ce4d1647..18e4d498 100644 --- a/docs_src/client/tutorial005.py +++ b/docs_src/client/tutorial005.py @@ -1,20 +1,17 @@ +import anyio + from mcp import Client -from mcp.server import MCPServer - -mcp = MCPServer("Bookshop") - - -@mcp.prompt(title="Recommend a book") -def recommend(genre: str) -> str: - """Ask for a recommendation in a genre.""" - return f"Recommend one {genre} book from the catalog and say why." async def main() -> None: - async with Client(mcp) as client: + async with Client("http://localhost:8000/mcp") as client: listed = await client.list_prompts() print(listed.prompts) result = await client.get_prompt("recommend", {"genre": "poetry"}) for message in result.messages: print(message.role, message.content) + + +if __name__ == "__main__": + anyio.run(main) diff --git a/docs_src/client/tutorial006.py b/docs_src/client/tutorial006.py index 370e0b79..5410e3b1 100644 --- a/docs_src/client/tutorial006.py +++ b/docs_src/client/tutorial006.py @@ -1,31 +1,17 @@ +import anyio + from mcp import Client -from mcp.server import MCPServer -from mcp.types import Completion, CompletionArgument, CompletionContext, PromptReference, ResourceTemplateReference - -mcp = MCPServer("Bookshop") - -GENRES = ["fiction", "non-fiction", "poetry"] - - -@mcp.prompt() -def recommend(genre: str) -> str: - """Ask for a recommendation in a genre.""" - return f"Recommend one {genre} book from the catalog and say why." - - -@mcp.completion() -async def complete_genre( - ref: PromptReference | ResourceTemplateReference, - argument: CompletionArgument, - context: CompletionContext | None, -) -> Completion | None: - return Completion(values=[genre for genre in GENRES if genre.startswith(argument.value)]) +from mcp.types import PromptReference async def main() -> None: - async with Client(mcp) as client: + async with Client("http://localhost:8000/mcp") as client: result = await client.complete( ref=PromptReference(type="ref/prompt", name="recommend"), argument={"name": "genre", "value": "p"}, ) print(result.completion.values) + + +if __name__ == "__main__": + anyio.run(main) diff --git a/docs_src/client/tutorial007.py b/docs_src/client/tutorial007.py index c5c918bc..85580114 100644 --- a/docs_src/client/tutorial007.py +++ b/docs_src/client/tutorial007.py @@ -1,30 +1,25 @@ +import anyio + from mcp import Client -from mcp.server import MCPServer from mcp.types import Tool -mcp = MCPServer("Bookshop") - -@mcp.tool() -def search_books(query: str) -> str: - """Search the catalog by title or author.""" - return f"Found 3 books matching {query!r}." - - -@mcp.tool() -def reserve_book(title: str) -> str: - """Put a book on hold.""" - return f"Reserved {title!r}." +async def list_all_tools(client: Client) -> list[Tool]: + tools: list[Tool] = [] + cursor: str | None = None + while True: + page = await client.list_tools(cursor=cursor) + tools.extend(page.tools) + if page.next_cursor is None: + return tools + cursor = page.next_cursor async def main() -> None: - async with Client(mcp) as client: - tools: list[Tool] = [] - cursor: str | None = None - while True: - page = await client.list_tools(cursor=cursor) - tools.extend(page.tools) - if page.next_cursor is None: - break - cursor = page.next_cursor + async with Client("http://localhost:8000/mcp") as client: + tools = await list_all_tools(client) print([tool.name for tool in tools]) + + +if __name__ == "__main__": + anyio.run(main) diff --git a/docs_src/extensions/tutorial003.py b/docs_src/extensions/tutorial003.py index 312371be..882e5e73 100644 --- a/docs_src/extensions/tutorial003.py +++ b/docs_src/extensions/tutorial003.py @@ -1,7 +1,6 @@ from collections.abc import Sequence from typing import Any -from mcp import Client from mcp.server.extension import Extension, ToolBinding from mcp.server.mcpserver import MCPServer @@ -24,12 +23,3 @@ class Stamps(Extension): mcp = MCPServer("post-office", extensions=[Stamps()]) - - -async def main() -> None: - async with Client(mcp) as client: - print(client.server_capabilities.extensions) - # {'com.example/stamps': {'sealed': True}} - result = await client.call_tool("stamp", {"text": "hello"}) - print(result.content) - # [TextContent(text='[stamped] hello')] diff --git a/docs_src/extensions/tutorial003_client.py b/docs_src/extensions/tutorial003_client.py new file mode 100644 index 00000000..3d1b47b8 --- /dev/null +++ b/docs_src/extensions/tutorial003_client.py @@ -0,0 +1,16 @@ +import anyio + +from mcp import Client + + +async def main() -> None: + async with Client("http://localhost:8000/mcp") as client: + print(client.server_capabilities.extensions) + # {'com.example/stamps': {'sealed': True}} + result = await client.call_tool("stamp", {"text": "hello"}) + print(result.content) + # [TextContent(type='text', text='[stamped] hello', annotations=None, meta=None)] + + +if __name__ == "__main__": + anyio.run(main) diff --git a/docs_src/extensions/tutorial004.py b/docs_src/extensions/tutorial004.py index d3e2ef3b..01cb3d8e 100644 --- a/docs_src/extensions/tutorial004.py +++ b/docs_src/extensions/tutorial004.py @@ -1,11 +1,9 @@ from collections.abc import Sequence -from typing import Any, Literal +from typing import Any from pydantic import Field import mcp.types as types -from mcp import Client -from mcp.client import advertise from mcp.server.context import ServerRequestContext from mcp.server.extension import Extension, MethodBinding from mcp.server.mcpserver import MCPServer, require_client_extension @@ -22,11 +20,6 @@ class SearchResult(types.Result): items: list[str] -class SearchRequest(types.Request[SearchParams, Literal["com.example/search"]]): - method: Literal["com.example/search"] = "com.example/search" - params: SearchParams - - async def search(ctx: ServerRequestContext[Any, Any], params: SearchParams) -> SearchResult: require_client_extension(ctx, EXTENSION_ID) return SearchResult(items=[f"{params.query}-{n}" for n in range(params.limit)]) @@ -49,11 +42,3 @@ class Search(Extension): mcp = MCPServer("catalog", extensions=[Search()]) - - -async def main() -> None: - async with Client(mcp, extensions=[advertise(EXTENSION_ID)]) as client: - request = SearchRequest(params=SearchParams(query="mcp", limit=3)) - result = await client.session.send_request(request, SearchResult) - print(result.items) - # ['mcp-0', 'mcp-1', 'mcp-2'] diff --git a/docs_src/extensions/tutorial004_client.py b/docs_src/extensions/tutorial004_client.py new file mode 100644 index 00000000..fdfcddc8 --- /dev/null +++ b/docs_src/extensions/tutorial004_client.py @@ -0,0 +1,35 @@ +from typing import Literal + +import anyio + +import mcp.types as types +from mcp import Client +from mcp.client import advertise + +EXTENSION_ID = "com.example/search" + + +class SearchParams(types.RequestParams): + query: str + limit: int = 10 + + +class SearchResult(types.Result): + items: list[str] + + +class SearchRequest(types.Request[SearchParams, Literal["com.example/search"]]): + method: Literal["com.example/search"] = "com.example/search" + params: SearchParams + + +async def main() -> None: + async with Client("http://localhost:8000/mcp", extensions=[advertise(EXTENSION_ID)]) as client: + request = SearchRequest(params=SearchParams(query="mcp", limit=3)) + result = await client.session.send_request(request, SearchResult) + print(result.items) + # ['mcp-0', 'mcp-1', 'mcp-2'] + + +if __name__ == "__main__": + anyio.run(main) diff --git a/docs_src/extensions/tutorial006.py b/docs_src/extensions/tutorial006.py index 88592af9..9d9f9966 100644 --- a/docs_src/extensions/tutorial006.py +++ b/docs_src/extensions/tutorial006.py @@ -1,9 +1,6 @@ -from collections.abc import Sequence -from typing import Any, Literal +from typing import Any import mcp.types as types -from mcp import Client -from mcp.client import ClaimContext, ClientExtension, ResultClaim from mcp.server.context import CallNext, HandlerResult, ServerRequestContext from mcp.server.extension import Extension from mcp.server.mcpserver import MCPServer, require_client_extension @@ -11,13 +8,6 @@ from mcp.server.mcpserver import MCPServer, require_client_extension EXTENSION_ID = "com.example/receipts" -class ReceiptResult(types.Result): - """The claimed result shape; `result_type` pins the wire tag.""" - - result_type: Literal["receipt"] = "receipt" - receipt_token: str - - class ReceiptIssuer(Extension): """Server half: answers `buy` with a receipt instead of a final result.""" @@ -35,18 +25,6 @@ class ReceiptIssuer(Extension): return {"resultType": "receipt", "receiptToken": "r-117"} -class Receipts(ClientExtension): - """Client half: claims the `receipt` shape and supplies the code that finishes it.""" - - identifier = EXTENSION_ID - - def claims(self) -> Sequence[ResultClaim[Any]]: - return [ResultClaim(result_type="receipt", model=ReceiptResult, resolve=self._redeem)] - - async def _redeem(self, claimed: ReceiptResult, ctx: ClaimContext) -> types.CallToolResult: - return await ctx.session.call_tool("redeem", {"token": claimed.receipt_token}) - - mcp = MCPServer("shop", extensions=[ReceiptIssuer()]) @@ -60,10 +38,3 @@ def buy(item: str) -> types.CallToolResult: def redeem(token: str) -> str: """Exchange a receipt token for the goods.""" return f"goods for {token}" - - -async def main() -> None: - async with Client(mcp, extensions=[Receipts()]) as client: - result = await client.call_tool("buy", {"item": "lamp"}) - print(result.content) - # [TextContent(text='goods for r-117')] diff --git a/docs_src/extensions/tutorial006_client.py b/docs_src/extensions/tutorial006_client.py new file mode 100644 index 00000000..11c655ca --- /dev/null +++ b/docs_src/extensions/tutorial006_client.py @@ -0,0 +1,40 @@ +from collections.abc import Sequence +from typing import Any, Literal + +import anyio + +import mcp.types as types +from mcp import Client +from mcp.client import ClaimContext, ClientExtension, ResultClaim + +EXTENSION_ID = "com.example/receipts" + + +class ReceiptResult(types.Result): + """The claimed result shape; `result_type` pins the wire tag.""" + + result_type: Literal["receipt"] = "receipt" + receipt_token: str + + +class Receipts(ClientExtension): + """Client half: claims the `receipt` shape and supplies the code that finishes it.""" + + identifier = EXTENSION_ID + + def claims(self) -> Sequence[ResultClaim[Any]]: + return [ResultClaim(result_type="receipt", model=ReceiptResult, resolve=self._redeem)] + + async def _redeem(self, claimed: ReceiptResult, ctx: ClaimContext) -> types.CallToolResult: + return await ctx.session.call_tool("redeem", {"token": claimed.receipt_token}) + + +async def main() -> None: + async with Client("http://localhost:8000/mcp", extensions=[Receipts()]) as client: + result = await client.call_tool("buy", {"item": "lamp"}) + print(result.content) + # [TextContent(type='text', text='goods for r-117', annotations=None, meta=None)] + + +if __name__ == "__main__": + anyio.run(main) diff --git a/docs_src/extensions/tutorial007.py b/docs_src/extensions/tutorial007.py index 182fc8f6..659c728f 100644 --- a/docs_src/extensions/tutorial007.py +++ b/docs_src/extensions/tutorial007.py @@ -1,9 +1,7 @@ from collections.abc import Sequence -from typing import Any, Literal +from typing import Any import mcp.types as types -from mcp import Client -from mcp.client import advertise from mcp.server.context import ServerRequestContext from mcp.server.extension import Extension, MethodBinding from mcp.server.mcpserver import MCPServer @@ -19,12 +17,6 @@ class JobStatus(types.Result): status: str -class JobStatusRequest(types.Request[JobParams, Literal["com.example/jobs.status"]]): - method: Literal["com.example/jobs.status"] = "com.example/jobs.status" - params: JobParams - name_param = "jobId" # params["jobId"] rides the Mcp-Name header - - async def job_status(ctx: ServerRequestContext[Any, Any], params: JobParams) -> JobStatus: return JobStatus(status=f"{params.job_id} is running") @@ -39,11 +31,3 @@ class Jobs(Extension): mcp = MCPServer("worker", extensions=[Jobs()]) - - -async def main() -> None: - async with Client(mcp, extensions=[advertise(EXTENSION_ID)]) as client: - request = JobStatusRequest(params=JobParams(job_id="job-7")) - result = await client.session.send_request(request, JobStatus) - print(result.status) - # job-7 is running diff --git a/docs_src/extensions/tutorial007_client.py b/docs_src/extensions/tutorial007_client.py new file mode 100644 index 00000000..0f7d7961 --- /dev/null +++ b/docs_src/extensions/tutorial007_client.py @@ -0,0 +1,35 @@ +from typing import Literal + +import anyio + +import mcp.types as types +from mcp import Client +from mcp.client import advertise + +EXTENSION_ID = "com.example/jobs" + + +class JobParams(types.RequestParams): + job_id: str + + +class JobStatus(types.Result): + status: str + + +class JobStatusRequest(types.Request[JobParams, Literal["com.example/jobs.status"]]): + method: Literal["com.example/jobs.status"] = "com.example/jobs.status" + params: JobParams + name_param = "jobId" # params["jobId"] rides the Mcp-Name header + + +async def main() -> None: + async with Client("http://localhost:8000/mcp", extensions=[advertise(EXTENSION_ID)]) as client: + request = JobStatusRequest(params=JobParams(job_id="job-7")) + result = await client.session.send_request(request, JobStatus) + print(result.status) + # job-7 is running + + +if __name__ == "__main__": + anyio.run(main) diff --git a/docs_src/first_steps/tutorial001_client.py b/docs_src/first_steps/tutorial001_client.py new file mode 100644 index 00000000..f0ba92ff --- /dev/null +++ b/docs_src/first_steps/tutorial001_client.py @@ -0,0 +1,12 @@ +import anyio + +from mcp import Client + + +async def main() -> None: + async with Client("http://localhost:8000/mcp") as client: + print(client.server_capabilities.model_dump(exclude_none=True)) + + +if __name__ == "__main__": + anyio.run(main) diff --git a/docs_src/legacy_clients/tutorial001.py b/docs_src/legacy_clients/tutorial001.py index 2090201f..998444de 100644 --- a/docs_src/legacy_clients/tutorial001.py +++ b/docs_src/legacy_clients/tutorial001.py @@ -2,11 +2,8 @@ from typing import Annotated from pydantic import BaseModel -from mcp import Client -from mcp.client import ClientRequestContext from mcp.server import MCPServer from mcp.server.mcpserver import AcceptedElicitation, Elicit, ElicitationResult, Resolve -from mcp.types import ElicitRequestParams, ElicitResult mcp = MCPServer("Bookshop") @@ -26,17 +23,3 @@ async def reserve(title: str, quantity: Annotated[ElicitationResult[Quantity], R if isinstance(quantity, AcceptedElicitation): return f"Reserved {quantity.data.copies} of {title!r}." return "Nothing reserved." - - -async def answer(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult: - return ElicitResult(action="accept", content={"copies": 2}) - - -async def main() -> None: - async with ( - Client(mcp, mode="legacy", elicitation_callback=answer) as legacy, - Client(mcp, elicitation_callback=answer) as modern, - ): - for client in (legacy, modern): - result = await client.call_tool("reserve", {"title": "Dune"}) - print(client.protocol_version, result.structured_content) diff --git a/docs_src/legacy_clients/tutorial001_client.py b/docs_src/legacy_clients/tutorial001_client.py new file mode 100644 index 00000000..afe0981e --- /dev/null +++ b/docs_src/legacy_clients/tutorial001_client.py @@ -0,0 +1,23 @@ +import anyio + +from mcp import Client +from mcp.client import ClientRequestContext +from mcp.types import ElicitRequestParams, ElicitResult + + +async def answer(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult: + return ElicitResult(action="accept", content={"copies": 2}) + + +async def main() -> None: + async with ( + Client("http://localhost:8000/mcp", mode="legacy", elicitation_callback=answer) as legacy, + Client("http://localhost:8000/mcp", elicitation_callback=answer) as modern, + ): + for client in (legacy, modern): + result = await client.call_tool("reserve", {"title": "Dune"}) + print(client.protocol_version, result.structured_content) + + +if __name__ == "__main__": + anyio.run(main) diff --git a/docs_src/lowlevel/tutorial001.py b/docs_src/lowlevel/tutorial001.py index 3b96aa2a..ac705a2e 100644 --- a/docs_src/lowlevel/tutorial001.py +++ b/docs_src/lowlevel/tutorial001.py @@ -30,3 +30,4 @@ async def call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> server = Server("Bookshop", on_list_tools=list_tools, on_call_tool=call_tool) +app = server.streamable_http_app() diff --git a/docs_src/pagination/tutorial001.py b/docs_src/pagination/tutorial001.py index 3bc97540..d1dc7c2a 100644 --- a/docs_src/pagination/tutorial001.py +++ b/docs_src/pagination/tutorial001.py @@ -17,3 +17,4 @@ async def list_books(ctx: ServerRequestContext[Any], params: PaginatedRequestPar server = Server("Bookshop", on_list_resources=list_books) +app = server.streamable_http_app() diff --git a/docs_src/pagination/tutorial002.py b/docs_src/pagination/tutorial002.py index f7284777..61e40bc7 100644 --- a/docs_src/pagination/tutorial002.py +++ b/docs_src/pagination/tutorial002.py @@ -1,33 +1,26 @@ -from typing import Any +import anyio from mcp import Client -from mcp.server import Server, ServerRequestContext -from mcp.types import ListResourcesResult, PaginatedRequestParams, Resource - -BOOKS = [f"book-{n}" for n in range(1, 101)] - -PAGE_SIZE = 10 +from mcp.types import Resource -async def list_books(ctx: ServerRequestContext[Any], params: PaginatedRequestParams | None) -> ListResourcesResult: - start = 0 if params is None or params.cursor is None else int(params.cursor) - end = start + PAGE_SIZE - page = [Resource(uri=f"books://catalog/{name}", name=name) for name in BOOKS[start:end]] - next_cursor = str(end) if end < len(BOOKS) else None - return ListResourcesResult(resources=page, next_cursor=next_cursor) - - -server = Server("Bookshop", on_list_resources=list_books) +async def list_all_resources(client: Client) -> list[Resource]: + resources: list[Resource] = [] + cursor: str | None = None + while True: + page = await client.list_resources(cursor=cursor) + resources.extend(page.resources) + if page.next_cursor is None: + break + cursor = page.next_cursor + return resources async def main() -> None: - async with Client(server) as client: - resources: list[Resource] = [] - cursor: str | None = None - while True: - page = await client.list_resources(cursor=cursor) - resources.extend(page.resources) - if page.next_cursor is None: - break - cursor = page.next_cursor + async with Client("http://localhost:8000/mcp") as client: + resources = await list_all_resources(client) print(f"{len(resources)} resources") + + +if __name__ == "__main__": + anyio.run(main) diff --git a/docs_src/protocol_versions/tutorial001.py b/docs_src/protocol_versions/tutorial001.py index 23570e3b..1da11030 100644 --- a/docs_src/protocol_versions/tutorial001.py +++ b/docs_src/protocol_versions/tutorial001.py @@ -1,15 +1,12 @@ +import anyio + from mcp import Client -from mcp.server import MCPServer - -mcp = MCPServer("Bookshop") - - -@mcp.tool() -def search_books(query: str) -> str: - """Search the catalog by title or author.""" - return f"Found 3 books matching {query!r}." async def main() -> None: - async with Client(mcp) as client: + async with Client("http://localhost:8000/mcp") as client: print(client.protocol_version) + + +if __name__ == "__main__": + anyio.run(main) diff --git a/docs_src/protocol_versions/tutorial002.py b/docs_src/protocol_versions/tutorial002.py index 5c00c8b4..d43ebb7b 100644 --- a/docs_src/protocol_versions/tutorial002.py +++ b/docs_src/protocol_versions/tutorial002.py @@ -1,15 +1,12 @@ +import anyio + from mcp import Client -from mcp.server import MCPServer - -mcp = MCPServer("Bookshop") - - -@mcp.tool() -def search_books(query: str) -> str: - """Search the catalog by title or author.""" - return f"Found 3 books matching {query!r}." async def main() -> None: - async with Client(mcp, mode="legacy") as client: + async with Client("http://localhost:8000/mcp", mode="legacy") as client: print(client.protocol_version) + + +if __name__ == "__main__": + anyio.run(main) diff --git a/docs_src/protocol_versions/tutorial003.py b/docs_src/protocol_versions/tutorial003.py index 5fd32ac1..e5ca3ca4 100644 --- a/docs_src/protocol_versions/tutorial003.py +++ b/docs_src/protocol_versions/tutorial003.py @@ -1,15 +1,12 @@ +import anyio + from mcp import Client -from mcp.server import MCPServer - -mcp = MCPServer("Bookshop") - - -@mcp.tool() -def search_books(query: str) -> str: - """Search the catalog by title or author.""" - return f"Found 3 books matching {query!r}." async def main() -> None: - async with Client(mcp, mode="2026-07-28") as client: + async with Client("http://localhost:8000/mcp", mode="2026-07-28") as client: print(client.protocol_version) + + +if __name__ == "__main__": + anyio.run(main) diff --git a/docs_src/protocol_versions/tutorial004.py b/docs_src/protocol_versions/tutorial004.py index dd0443b9..36087769 100644 --- a/docs_src/protocol_versions/tutorial004.py +++ b/docs_src/protocol_versions/tutorial004.py @@ -1,20 +1,17 @@ +import anyio + from mcp import Client -from mcp.server import MCPServer - -mcp = MCPServer("Bookshop") - - -@mcp.tool() -def search_books(query: str) -> str: - """Search the catalog by title or author.""" - return f"Found 3 books matching {query!r}." async def main() -> None: - async with Client(mcp) as client: + async with Client("http://localhost:8000/mcp") as client: saved = client.session.discover_result - async with Client(mcp, mode="2026-07-28", prior_discover=saved) as client: + async with Client("http://localhost:8000/mcp", mode="2026-07-28", prior_discover=saved) as client: print(client.protocol_version) if client.server_info is not None: print(client.server_info.name) + + +if __name__ == "__main__": + anyio.run(main) diff --git a/examples/stories/bearer_auth/README.md b/examples/stories/bearer_auth/README.md index 9e809fad..530f048a 100644 --- a/examples/stories/bearer_auth/README.md +++ b/examples/stories/bearer_auth/README.md @@ -78,7 +78,7 @@ kill "$SERVER_PID" - `RESOURCE_URL` is hard-coded to port 8000 (the harness's in-process origin). If you change `--port`, edit `RESOURCE_URL` to match or the PRM document's `resource` field will be wrong. -- Auth is HTTP-only; over stdio or the in-memory transport `get_access_token()` +- Auth is HTTP-only; over stdio (or the in-memory test transport) `get_access_token()` returns `None` and there is no gate. - The 401/403 status codes and `WWW-Authenticate` header are HTTP-level and `Client` cannot observe them; they are pinned by diff --git a/examples/stories/identity_assertion/README.md b/examples/stories/identity_assertion/README.md index 1002a5f7..68b986df 100644 --- a/examples/stories/identity_assertion/README.md +++ b/examples/stories/identity_assertion/README.md @@ -87,7 +87,7 @@ kill "$SERVER_PID" `exp` has passed and expires tokens out of its own store. - `transport_security=NO_DNS_REBIND` is harness-only; drop it for a real deployment. -- Auth is HTTP-only; over stdio or the in-memory transport there is no gate. +- Auth is HTTP-only; over stdio (or the in-memory test transport) there is no gate. ## Spec diff --git a/examples/stories/pagination/README.md b/examples/stories/pagination/README.md index f7113d4c..19fd7f0d 100644 --- a/examples/stories/pagination/README.md +++ b/examples/stories/pagination/README.md @@ -22,8 +22,8 @@ Drop `--server server_lowlevel` (on either transport) to run against the - `client.py` `main` — `async with Client(target, mode=mode) as client:` is the whole connection. The story owns the construction; `target` is whatever - `Client()` accepts (an in-process server, a transport, or an HTTP URL) and - the entry point picks it. + `Client()` accepts (an HTTP URL, `StdioServerParameters`, a transport, or, + in tests, an in-process server) and the entry point picks it. - `client.py` — `if page.next_cursor is None: break`. Termination is key-absent, not falsy; `while cursor:` would be a spec bug. - `server_lowlevel.py` — the handler owns the cursor encoding (here: an diff --git a/examples/stories/prompts/README.md b/examples/stories/prompts/README.md index 3bce94b9..7d184768 100644 --- a/examples/stories/prompts/README.md +++ b/examples/stories/prompts/README.md @@ -21,8 +21,9 @@ uv run python -m stories.prompts.client --http --server server_lowlevel ## What to look at - `client.py` `main` — the body opens with `async with Client(target, - mode=mode) as client:`; `target` is anything `Client(...)` accepts (an - in-process server, a `Transport`, or an HTTP URL). + mode=mode) as client:`; `target` is anything `Client(...)` accepts (an HTTP + URL, `StdioServerParameters`, a `Transport`, or, in tests, an in-process + server). - `server.py` `greet` vs `code_review` — return a bare `str` (wrapped as one user message) or a `list[Message]` for a multi-turn seed conversation. - `server.py` `complete()` — one global handler dispatches on `ref` + diff --git a/examples/stories/reconnect/README.md b/examples/stories/reconnect/README.md index a5d3d8f5..f07befd8 100644 --- a/examples/stories/reconnect/README.md +++ b/examples/stories/reconnect/README.md @@ -9,7 +9,7 @@ traffic and has `server_info` / `server_capabilities` available immediately. ## Run it ```bash -# over HTTP — Streamable HTTP only; in-memory has no "round-trip" to skip. +# HTTP only: the point of this story is skipping a network round trip. # The client self-hosts the server on a free port, runs, then tears it down. uv run python -m stories.reconnect.client --http # same, against the lowlevel-API server variant diff --git a/examples/stories/resources/README.md b/examples/stories/resources/README.md index 10b210fe..0436c033 100644 --- a/examples/stories/resources/README.md +++ b/examples/stories/resources/README.md @@ -21,8 +21,9 @@ uv run python -m stories.resources.client --http --server server_lowlevel - `client.py` `async with Client(target, mode=mode) as client:` — the one line every client example exists to teach. `target` is anything `Client()` - accepts (an in-process server, a transport, or an HTTP URL) and `mode=` is - always explicit; the rest of the story is the body of that `async with`. + accepts (an HTTP URL, `StdioServerParameters`, a transport, or, in tests, an + in-process server) and `mode=` is always explicit; the rest of the story is + the body of that `async with`. - `server.py` `app_config` vs `greeting` — a URI with no `{}` registers a static resource (appears in `resources/list`); a URI with `{name}` registers a template (appears only in `resources/templates/list`) and the placeholder diff --git a/examples/stories/schema_validators/README.md b/examples/stories/schema_validators/README.md index 984f1595..62b3d098 100644 --- a/examples/stories/schema_validators/README.md +++ b/examples/stories/schema_validators/README.md @@ -20,8 +20,9 @@ uv run python -m stories.schema_validators.client --http --server server_lowleve ## What to look at - `client.py` `main` — the body opens with `async with Client(target, mode=mode) - as client:`. `target` is anything `Client` accepts (an in-process server, a - transport, or an HTTP URL); the entry point picks it, the story constructs it. + as client:`. `target` is anything `Client` accepts (an HTTP URL, + `StdioServerParameters`, a transport, or, in tests, an in-process server); + the entry point picks it, the story constructs it. - `server.py` — `who.name` vs `who["name"]`: pydantic and dataclass parameters arrive as **instances** (attribute access); TypedDict and `dict[str, Any]` arrive as plain dicts. diff --git a/src/mcp/client/client.py b/src/mcp/client/client.py index 2f467c26..f921c7e3 100644 --- a/src/mcp/client/client.py +++ b/src/mcp/client/client.py @@ -268,17 +268,12 @@ class Client: Example: ```python - from mcp.client import Client - from mcp.server.mcpserver import MCPServer + import asyncio - server = MCPServer("test") - - @server.tool() - def add(a: int, b: int) -> int: - return a + b + from mcp import Client async def main(): - async with Client(server) as client: + async with Client("http://localhost:8000/mcp") as client: result = await client.call_tool("add", {"a": 1, "b": 2}) asyncio.run(main()) diff --git a/tests/docs_src/test_apps.py b/tests/docs_src/test_apps.py index e91d12a3..b82b9c03 100644 --- a/tests/docs_src/test_apps.py +++ b/tests/docs_src/test_apps.py @@ -5,9 +5,8 @@ from typing import Any import pytest from mcp_types import TextContent, TextResourceContents -from docs_src.apps import tutorial001, tutorial002, tutorial003 +from docs_src.apps import tutorial001, tutorial001_client, tutorial002, tutorial003 from mcp import Client -from mcp.client import advertise from mcp.server.apps import APP_MIME_TYPE, EXTENSION_ID # See test_index.py for why this is a per-module mark and not a conftest hook. @@ -33,11 +32,10 @@ async def test_the_ui_resource_is_served_as_the_app_mime_type() -> None: async def test_one_tool_two_answers() -> None: - """tutorial001: the canonical degradation pattern: raw data for a client that - negotiated Apps, a human sentence for one that did not.""" - async with Client( - tutorial001.mcp, extensions=[advertise(EXTENSION_ID, {"mimeTypes": [APP_MIME_TYPE]})] - ) as ui_client: + """tutorial001_client's `APPS_SUPPORT` declaration, driven in-process against + tutorial001's server: the client that negotiated Apps (with the required + `mimeTypes`) gets raw data, one that did not gets the human sentence.""" + async with Client(tutorial001.mcp, extensions=[tutorial001_client.APPS_SUPPORT]) as ui_client: rich = await ui_client.call_tool("get_time", {}) async with Client(tutorial001.mcp) as text_client: plain = await text_client.call_tool("get_time", {}) @@ -45,13 +43,6 @@ async def test_one_tool_two_answers() -> None: assert plain.content == [TextContent(type="text", text="The time is 2026-06-26T12:00:00Z.")] -async def test_the_clock_client_program_runs_as_shown(capsys: pytest.CaptureFixture[str]) -> None: - """tutorial001: `main()` declares Apps support with the required `mimeTypes` and - receives the rich answer the page promises.""" - await tutorial001.main() - assert "2026-06-26T12:00:00Z" in capsys.readouterr().out - - async def test_capability_advertised_under_server_extensions() -> None: """tutorial001: passing `extensions=[apps]` advertises `io.modelcontextprotocol/ui`.""" async with Client(tutorial001.mcp) as client: diff --git a/tests/docs_src/test_caching.py b/tests/docs_src/test_caching.py index 751ca861..9a697fa2 100644 --- a/tests/docs_src/test_caching.py +++ b/tests/docs_src/test_caching.py @@ -64,9 +64,13 @@ async def test_the_handler_value_wins_over_the_map_per_field() -> None: async def test_the_client_program_on_the_page_makes_three_fetches_for_four_calls( capsys: pytest.CaptureFixture[str], ) -> None: - """tutorial003: a cache hit, an expiry, and `cache_mode="refresh"` make four calls cost three fetches.""" - await tutorial003.main() - assert capsys.readouterr().out == "4 calls, 3 fetches\n" + """tutorial003's four calls, driven in-process against tutorial002's server with the demo's own + clock: a cache hit, an expiry, and `cache_mode="refresh"` make them cost three fetches, each one + a line the server printed. The first result carries the handler's TTL and the map's scope.""" + async with Client(tutorial002.server, cache=CacheConfig(clock=lambda: tutorial003.clock.now)) as client: + tools = await tutorial003.run(client) + assert (tools.ttl_ms, tools.cache_scope) == (1_000, "public") + assert capsys.readouterr().out == "tools/list served\n" * 3 def _counting_tools_server(*, ttl_ms: int | None = 60_000) -> tuple[Server[Any], list[str | None]]: diff --git a/tests/docs_src/test_client.py b/tests/docs_src/test_client.py index d07ee4c9..7121e98d 100644 --- a/tests/docs_src/test_client.py +++ b/tests/docs_src/test_client.py @@ -1,10 +1,14 @@ -"""`docs/client/index.md`: every claim the page makes, proved against the real SDK.""" +"""`docs/client/index.md`: every claim the page makes, proved against the real SDK. + +`tutorial001` is the page's `server.py`. The other snippets are `client.py` programs that reach it +by URL, so each test drives the same calls in-process against `tutorial001.mcp` instead. +""" import pytest from inline_snapshot import snapshot from mcp_types import Prompt, PromptArgument, PromptReference, TextContent, TextResourceContents, Tool -from docs_src.client import tutorial001, tutorial002, tutorial003, tutorial004, tutorial005, tutorial006, tutorial007 +from docs_src.client import tutorial001, tutorial007 from mcp import Client, MCPDeprecationWarning, MCPError from mcp.shared.metadata_utils import get_display_name @@ -12,31 +16,23 @@ from mcp.shared.metadata_utils import get_display_name pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")] -async def test_every_client_program_on_the_page_runs(capsys: pytest.CaptureFixture[str]) -> None: - """Each `main()` is the literal client program shown on the page; all seven run clean in-memory.""" - await tutorial001.main() - await tutorial002.main() - await tutorial003.main() - await tutorial004.main() - await tutorial005.main() - await tutorial006.main() - await tutorial007.main() - assert "Bookshop" in capsys.readouterr().out - - async def test_connected_properties_are_populated_inside_the_block() -> None: - """tutorial001: server_info, server_capabilities, protocol_version and instructions are just there.""" + """tutorial001_client's four prints, in-process against server.py: server_info, capabilities, protocol_version + and instructions are just there, and a capability the server lacks is None.""" async with Client(tutorial001.mcp) as client: assert client.server_info is not None assert client.server_info.name == "Bookshop" assert client.protocol_version == "2026-07-28" assert client.instructions == "Search the catalog before recommending a book." assert client.server_capabilities.tools is not None + assert client.server_capabilities.resources is not None + assert client.server_capabilities.prompts is not None + assert client.server_capabilities.completions is not None assert client.server_capabilities.logging is None async def test_a_client_is_not_reusable_after_the_block_ends() -> None: - """tutorial001: `async with` is the whole lifecycle. Construct a new Client per connection.""" + """The lifecycle bullet: `async with` is the whole lifecycle. Construct a new Client per connection.""" client = Client(tutorial001.mcp) async with client: assert client.server_info is not None @@ -46,13 +42,14 @@ async def test_a_client_is_not_reusable_after_the_block_ends() -> None: async def test_list_tools_returns_the_full_definition() -> None: - """tutorial002: each listed tool carries its name, title, description and the derived input schema.""" - async with Client(tutorial002.mcp) as client: - (tool,) = (await client.list_tools()).tools - assert tool.name == "search_books" - assert tool.title == "Search the catalog" - assert tool.description == "Search the catalog by title or author." - assert tool.input_schema == snapshot( + """tutorial002's listing, in-process: each tool carries its name, title, description and the derived + input schema, and a tool registered without `title=` lists with title None.""" + async with Client(tutorial001.mcp) as client: + (search, lookup) = (await client.list_tools()).tools + assert search.name == "search_books" + assert search.title == "Search the catalog" + assert search.description == "Search the catalog by title or author." + assert search.input_schema == snapshot( { "type": "object", "properties": { @@ -63,6 +60,8 @@ async def test_list_tools_returns_the_full_definition() -> None: "title": "search_booksArguments", } ) + assert lookup.name == "lookup_book" + assert lookup.title is None def test_get_display_name_prefers_the_title() -> None: @@ -74,8 +73,8 @@ def test_get_display_name_prefers_the_title() -> None: async def test_call_tool_result_has_three_things_to_read() -> None: - """tutorial003: content for the model, structured_content for code, is_error for both.""" - async with Client(tutorial003.mcp) as client: + """tutorial003's call, in-process: content for the model, structured_content for code, is_error for both.""" + async with Client(tutorial001.mcp) as client: result = await client.call_tool("lookup_book", {"title": "Dune"}) assert not result.is_error (block,) = result.content @@ -85,8 +84,8 @@ async def test_call_tool_result_has_three_things_to_read() -> None: async def test_a_raising_tool_is_a_result_not_an_exception() -> None: - """tutorial003 `!!! check`: the ToolError's message comes back in content with is_error=True.""" - async with Client(tutorial003.mcp) as client: + """The `!!! check`: the ToolError's message comes back in content with is_error=True.""" + async with Client(tutorial001.mcp) as client: result = await client.call_tool("lookup_book", {"title": "Solaris"}) assert result.is_error (block,) = result.content @@ -97,7 +96,7 @@ async def test_a_raising_tool_is_a_result_not_an_exception() -> None: async def test_an_unknown_tool_name_is_a_result_not_an_exception() -> None: """The `!!! warning`: a tool the server doesn't have comes back as is_error=True, not as MCPError.""" - async with Client(tutorial003.mcp) as client: + async with Client(tutorial001.mcp) as client: result = await client.call_tool("does_not_exist", {}) assert result.is_error (block,) = result.content @@ -107,8 +106,9 @@ async def test_an_unknown_tool_name_is_a_result_not_an_exception() -> None: async def test_resources_and_templates_are_two_separate_lists() -> None: - """tutorial004: concrete resources and parameterised templates come back from different verbs.""" - async with Client(tutorial004.mcp) as client: + """tutorial004's two listings, in-process: concrete resources and parameterised templates come back + from different verbs.""" + async with Client(tutorial001.mcp) as client: (resource,) = (await client.list_resources()).resources assert resource.uri == "catalog://genres" (template,) = (await client.list_resource_templates()).resource_templates @@ -116,8 +116,9 @@ async def test_resources_and_templates_are_two_separate_lists() -> None: async def test_read_resource_fills_in_a_template() -> None: - """tutorial004: read_resource takes a plain str URI; narrow the contents with isinstance.""" - async with Client(tutorial004.mcp) as client: + """tutorial004's read, in-process: read_resource takes a plain str URI, the server matches it to the + template, and the contents narrow with isinstance.""" + async with Client(tutorial001.mcp) as client: (contents,) = (await client.read_resource("catalog://genres/poetry")).contents assert isinstance(contents, TextResourceContents) assert contents.text == "3 books filed under poetry." @@ -126,7 +127,7 @@ async def test_read_resource_fills_in_a_template() -> None: async def test_resource_subscriptions_are_listen_based_on_the_modern_wire() -> None: """The Resources section: at 2026-07-28 `resources.subscribe` is True (served via subscriptions/listen) while the legacy subscribe_resource verb answers -32601.""" - async with Client(tutorial004.mcp) as client: + async with Client(tutorial001.mcp) as client: assert client.server_capabilities.resources is not None assert client.server_capabilities.resources.subscribe is True with pytest.raises(MCPError) as exc_info: @@ -138,8 +139,8 @@ async def test_resource_subscriptions_are_listen_based_on_the_modern_wire() -> N async def test_list_prompts_describes_the_arguments() -> None: - """tutorial005: a listed prompt carries its name, title and the arguments it needs.""" - async with Client(tutorial005.mcp) as client: + """tutorial005's listing, in-process: a listed prompt carries its name, title and the arguments it needs.""" + async with Client(tutorial001.mcp) as client: (prompt,) = (await client.list_prompts()).prompts assert prompt == snapshot( Prompt( @@ -152,8 +153,8 @@ async def test_list_prompts_describes_the_arguments() -> None: async def test_get_prompt_renders_the_messages() -> None: - """tutorial005: get_prompt returns the rendered messages a host hands to the model.""" - async with Client(tutorial005.mcp) as client: + """tutorial005's render, in-process: get_prompt returns the messages a host hands to the model.""" + async with Client(tutorial001.mcp) as client: result = await client.get_prompt("recommend", {"genre": "poetry"}) (message,) = result.messages assert message.role == "user" @@ -163,8 +164,9 @@ async def test_get_prompt_renders_the_messages() -> None: async def test_complete_suggests_values_for_an_argument() -> None: - """tutorial006: complete takes a ref and a name/value pair and returns the matching values.""" - async with Client(tutorial006.mcp) as client: + """tutorial006's call, in-process: complete takes a ref and a name/value pair and returns the matching + values.""" + async with Client(tutorial001.mcp) as client: result = await client.complete( ref=PromptReference(type="ref/prompt", name="recommend"), argument={"name": "genre", "value": "p"}, @@ -172,16 +174,17 @@ async def test_complete_suggests_values_for_an_argument() -> None: assert result.completion.values == ["poetry"] -async def test_a_single_page_server_ends_the_pagination_loop_immediately() -> None: - """tutorial007: every list_* takes cursor=; next_cursor is None when there is nothing left.""" - async with Client(tutorial007.mcp) as client: - page = await client.list_tools(cursor=None) - assert page.next_cursor is None - assert [tool.name for tool in page.tools] == ["search_books", "reserve_book"] +async def test_the_pagination_loop_collects_every_tool_from_a_single_page_server() -> None: + """tutorial007's `list_all_tools`, driven in-process against server.py: MCPServer answers in one page, + so the loop ends on the first `next_cursor is None` with every tool collected.""" + async with Client(tutorial001.mcp) as client: + tools = await tutorial007.list_all_tools(client) + assert [tool.name for tool in tools] == ["search_books", "lookup_book"] + assert (await client.list_tools(cursor=None)).next_cursor is None async def test_raise_exceptions_is_a_constructor_flag() -> None: """The `## In tests` section: `raise_exceptions=True` is accepted by the in-memory Client.""" async with Client(tutorial001.mcp, raise_exceptions=True) as client: result = await client.call_tool("search_books", {"query": "dune"}) - assert result.structured_content == {"result": "Found 3 books matching 'dune'."} + assert result.structured_content == {"result": "Found 3 books matching 'dune' (showing up to 10)."} diff --git a/tests/docs_src/test_extensions.py b/tests/docs_src/test_extensions.py index 2a141337..b0c7d2e0 100644 --- a/tests/docs_src/test_extensions.py +++ b/tests/docs_src/test_extensions.py @@ -11,9 +11,12 @@ from docs_src.extensions import ( tutorial002, tutorial003, tutorial004, + tutorial004_client, tutorial005, tutorial006, + tutorial006_client, tutorial007, + tutorial007_client, ) from mcp import Client, MCPError from mcp.client import advertise @@ -42,13 +45,15 @@ def test_a_prefixless_identifier_fails_at_class_definition() -> None: async def test_extension_settings_advertised_under_capabilities() -> None: - """tutorial003: `settings()` becomes the entry at `capabilities.extensions[identifier]`.""" + """tutorial003: `settings()` becomes the entry at `capabilities.extensions[identifier]`, + which is the first line tutorial003_client prints.""" async with Client(tutorial003.mcp) as client: assert client.server_capabilities.extensions == {"com.example/stamps": {"sealed": True}} async def test_contributed_tool_is_listed_and_callable() -> None: - """tutorial003: a `ToolBinding` registers like any `add_tool` call: listed and callable.""" + """tutorial003: a `ToolBinding` registers like any `add_tool` call: listed and callable, + with the content tutorial003_client prints.""" async with Client(tutorial003.mcp) as client: listed = await client.list_tools() assert [tool.name for tool in listed.tools] == ["stamp"] @@ -56,28 +61,23 @@ async def test_contributed_tool_is_listed_and_callable() -> None: assert result.content == [TextContent(type="text", text="[stamped] hello")] -async def test_the_stamps_client_program_runs_as_shown(capsys: pytest.CaptureFixture[str]) -> None: - """tutorial003: `main()` is the literal client program on the page; both printed - lines match the page's comments.""" - await tutorial003.main() - out = capsys.readouterr().out - assert "{'com.example/stamps': {'sealed': True}}" in out - assert "[stamped] hello" in out - - -async def test_the_search_client_program_runs_as_shown(capsys: pytest.CaptureFixture[str]) -> None: - """tutorial004: `main()` declares the extension and gets the vendor method's result.""" - await tutorial004.main() - assert "['mcp-0', 'mcp-1', 'mcp-2']" in capsys.readouterr().out +async def test_declaring_client_gets_the_vendor_method_result() -> None: + """tutorial004_client's request against tutorial004's server, driven in-process: a client + that advertises the extension gets the vendor method's typed result, and the client's + own copy of the wire types agrees with the server's.""" + async with Client(tutorial004.mcp, extensions=[advertise(tutorial004_client.EXTENSION_ID)]) as client: + request = tutorial004_client.SearchRequest(params=tutorial004_client.SearchParams(query="mcp", limit=3)) + result = await client.session.send_request(request, tutorial004_client.SearchResult) + assert result.items == ["mcp-0", "mcp-1", "mcp-2"] async def test_vendor_method_rejects_a_non_declaring_client_with_32021() -> None: """tutorial004: `require_client_extension` answers a non-declaring client with `-32021` and the machine-readable `requiredCapabilities` payload.""" async with Client(tutorial004.mcp) as client: - request = tutorial004.SearchRequest(params=tutorial004.SearchParams(query="mcp")) + request = tutorial004_client.SearchRequest(params=tutorial004_client.SearchParams(query="mcp")) with pytest.raises(MCPError) as exc_info: - await client.session.send_request(request, tutorial004.SearchResult) + await client.session.send_request(request, tutorial004_client.SearchResult) assert exc_info.value.code == MISSING_REQUIRED_CLIENT_CAPABILITY assert exc_info.value.error.data == {"requiredCapabilities": {"extensions": {"com.example/search": {}}}} @@ -85,10 +85,12 @@ async def test_vendor_method_rejects_a_non_declaring_client_with_32021() -> None async def test_version_pinned_method_is_not_found_on_a_legacy_connection() -> None: """tutorial004: `protocol_versions={"2026-07-28"}` makes the method METHOD_NOT_FOUND at any other wire version; for a legacy client it doesn't exist.""" - async with Client(tutorial004.mcp, mode="legacy", extensions=[advertise(tutorial004.EXTENSION_ID)]) as client: - request = tutorial004.SearchRequest(params=tutorial004.SearchParams(query="mcp")) + async with Client( + tutorial004.mcp, mode="legacy", extensions=[advertise(tutorial004_client.EXTENSION_ID)] + ) as client: + request = tutorial004_client.SearchRequest(params=tutorial004_client.SearchParams(query="mcp")) with pytest.raises(MCPError) as exc_info: - await client.session.send_request(request, tutorial004.SearchResult) + await client.session.send_request(request, tutorial004_client.SearchResult) assert exc_info.value.code == METHOD_NOT_FOUND @@ -104,10 +106,12 @@ async def test_interceptor_observes_the_call_and_passes_the_result_through( assert messages == ["tool 'add' called"] -async def test_the_receipts_client_program_runs_as_shown(capsys: pytest.CaptureFixture[str]) -> None: - """tutorial006: `main()` runs as printed and the output is the redeemed result, never the claimed shape.""" - await tutorial006.main() - assert "goods for r-117" in capsys.readouterr().out +async def test_declaring_client_receives_the_redeemed_result_not_the_claimed_shape() -> None: + """tutorial006_client's `Receipts` against tutorial006's server, driven in-process: + `call_tool("buy")` returns what the resolver redeemed, never the claimed receipt shape.""" + async with Client(tutorial006.mcp, extensions=[tutorial006_client.Receipts()]) as client: + result = await client.call_tool("buy", {"item": "lamp"}) + assert result.content == [TextContent(type="text", text="goods for r-117")] async def test_a_client_without_the_extension_is_refused_by_the_gate() -> None: @@ -120,13 +124,17 @@ async def test_a_client_without_the_extension_is_refused_by_the_gate() -> None: async def test_session_tier_allow_claimed_returns_the_raw_shape() -> None: """The page's escape hatch: `allow_claimed=True` returns the parsed claim model, not the resolved result.""" - async with Client(tutorial006.mcp, extensions=[tutorial006.Receipts()]) as client: + async with Client(tutorial006.mcp, extensions=[tutorial006_client.Receipts()]) as client: result = await client.session.call_tool("buy", {"item": "lamp"}, allow_claimed=True) - assert isinstance(result, tutorial006.ReceiptResult) + assert isinstance(result, tutorial006_client.ReceiptResult) assert result.receipt_token == "r-117" -async def test_the_jobs_client_program_runs_as_shown(capsys: pytest.CaptureFixture[str]) -> None: - """tutorial007: a vendor request with `name_param` round-trips `send_request` with no registration.""" - await tutorial007.main() - assert "job-7 is running" in capsys.readouterr().out +async def test_name_param_request_round_trips_with_no_client_registration() -> None: + """tutorial007_client's `JobStatusRequest` against tutorial007's server, driven in-process: + a vendor request declaring `name_param` round-trips `send_request` with no client-side + registration.""" + async with Client(tutorial007.mcp, extensions=[advertise(tutorial007_client.EXTENSION_ID)]) as client: + request = tutorial007_client.JobStatusRequest(params=tutorial007_client.JobParams(job_id="job-7")) + result = await client.session.send_request(request, tutorial007_client.JobStatus) + assert result.status == "job-7 is running" diff --git a/tests/docs_src/test_legacy_clients.py b/tests/docs_src/test_legacy_clients.py index 0b2f1e08..90daf1bd 100644 --- a/tests/docs_src/test_legacy_clients.py +++ b/tests/docs_src/test_legacy_clients.py @@ -6,7 +6,7 @@ import httpx2 import pytest from mcp_types import INVALID_REQUEST, ResourceUpdatedNotification, TextContent -from docs_src.legacy_clients import tutorial001, tutorial002, tutorial003 +from docs_src.legacy_clients import tutorial001, tutorial001_client, tutorial002, tutorial003 from mcp import Client, MCPError from mcp.client.streamable_http import streamable_http_client from mcp.server import MCPServer @@ -25,14 +25,21 @@ MCP_HEADERS = {"Accept": "application/json, text/event-stream", "Content-Type": URL = "http://localhost:8000/mcp" -async def test_one_resolve_tool_serves_a_legacy_and_a_modern_client_at_once( - capsys: pytest.CaptureFixture[str], -) -> None: - """tutorial001's `main()`, exactly as the page renders it: two eras of client, one server, one answer.""" - await tutorial001.main() - assert capsys.readouterr().out == ( - """2025-11-25 {'result': "Reserved 2 of 'Dune'."}\n2026-07-28 {'result': "Reserved 2 of 'Dune'."}\n""" - ) +async def test_one_resolve_tool_serves_a_legacy_and_a_modern_client_at_once() -> None: + """tutorial001_client's flow, driven in-process against tutorial001's server: two eras of client open at + once with the page's `answer` callback, one `Resolve` tool, and the two lines the page prints.""" + lines: list[tuple[str, object]] = [] + async with ( + Client(tutorial001.mcp, mode="legacy", elicitation_callback=tutorial001_client.answer) as legacy, + Client(tutorial001.mcp, elicitation_callback=tutorial001_client.answer) as modern, + ): + for client in (legacy, modern): + result = await client.call_tool("reserve", {"title": "Dune"}) + lines.append((client.protocol_version, result.structured_content)) + assert lines == [ + ("2025-11-25", {"result": "Reserved 2 of 'Dune'."}), + ("2026-07-28", {"result": "Reserved 2 of 'Dune'."}), + ] async def test_neither_era_of_client_sees_the_resolved_parameter() -> None: @@ -114,13 +121,13 @@ async def test_stateless_http_kills_the_legacy_back_channel_and_only_the_legacy_ httpx2.AsyncClient(transport=transport) as http, ): modern_target = streamable_http_client(URL, http_client=http) - async with Client(modern_target, elicitation_callback=tutorial001.answer) as modern: + async with Client(modern_target, elicitation_callback=tutorial001_client.answer) as modern: assert modern.protocol_version == "2026-07-28" result = await modern.call_tool("reserve", {"title": "Dune"}) assert result.content == [TextContent(type="text", text="Reserved 2 of 'Dune'.")] legacy_target = streamable_http_client(URL, http_client=http) - async with Client(legacy_target, mode="legacy", elicitation_callback=tutorial001.answer) as legacy: + async with Client(legacy_target, mode="legacy", elicitation_callback=tutorial001_client.answer) as legacy: assert legacy.protocol_version == "2025-11-25" with pytest.raises(MCPError) as exc_info: # pragma: no branch await legacy.call_tool("reserve", {"title": "Dune"}) diff --git a/tests/docs_src/test_lowlevel.py b/tests/docs_src/test_lowlevel.py index 7d58e941..8a89aa01 100644 --- a/tests/docs_src/test_lowlevel.py +++ b/tests/docs_src/test_lowlevel.py @@ -11,6 +11,7 @@ from mcp_types import ( RequestParams, TextContent, ) +from starlette.routing import Route from docs_src.lowlevel import tutorial001, tutorial002, tutorial003, tutorial004, tutorial005, tutorial006 from mcp import Client, MCPError @@ -45,6 +46,13 @@ async def test_the_client_does_not_care_which_server_class_it_connects_to() -> N assert result.structured_content is None +def test_the_last_line_is_an_asgi_app_uvicorn_can_serve() -> None: + """tutorial001: the Try-it's `uvicorn server:app` target is a Starlette app with one route at `/mcp`.""" + (route,) = tutorial001.app.routes + assert isinstance(route, Route) + assert route.path == "/mcp" + + async def test_only_the_handlers_you_passed_become_capabilities() -> None: """tutorial001: two tool handlers advertise `tools` and nothing else.""" async with Client(tutorial001.server) as client: diff --git a/tests/docs_src/test_pagination.py b/tests/docs_src/test_pagination.py index ab5949df..e1c9eb9c 100644 --- a/tests/docs_src/test_pagination.py +++ b/tests/docs_src/test_pagination.py @@ -1,7 +1,6 @@ """`docs/advanced/pagination.md`: every claim the page makes, proved against the real SDK.""" import pytest -from mcp_types import Resource from docs_src.pagination import tutorial001, tutorial002 from mcp import Client, MCPError @@ -48,27 +47,20 @@ async def test_the_last_page_carries_no_cursor() -> None: assert page.next_cursor is None -async def test_the_loop_collects_all_one_hundred() -> None: - """tutorial001: the `cursor=` loop visits ten pages and reassembles the whole catalog.""" +async def test_the_client_loop_collects_all_one_hundred_in_order() -> None: + """tutorial002's `list_all_resources()`, driven in-process against tutorial001's server: the `cursor=` loop + stitches the pages back into the whole catalog, in order, with no gaps and no repeats.""" async with Client(tutorial001.server) as client: - resources: list[Resource] = [] - cursor: str | None = None - pages = 0 - while True: - page = await client.list_resources(cursor=cursor) - resources.extend(page.resources) - pages += 1 - if page.next_cursor is None: - break - cursor = page.next_cursor - assert pages == 10 - assert len({resource.uri for resource in resources}) == 100 + resources = await tutorial002.list_all_resources(client) + assert [resource.name for resource in resources] == tutorial001.BOOKS -async def test_the_client_program_on_the_page_runs(capsys: pytest.CaptureFixture[str]) -> None: - """tutorial002: `main()` is the literal client program on the page and prints the stitched total.""" - await tutorial002.main() - assert capsys.readouterr().out == "100 resources\n" +async def test_the_client_loop_runs_once_against_a_server_that_does_not_page() -> None: + """tutorial002's loop against the `MCPServer` above: `next_cursor` is `None` on the first response, so one + pass returns the whole catalog.""" + async with Client(mcp) as client: + resources = await tutorial002.list_all_resources(client) + assert [resource.name for resource in resources] == [f"book-{n}" for n in range(1, 101)] async def test_an_invented_cursor_is_an_error() -> None: diff --git a/tests/docs_src/test_progress.py b/tests/docs_src/test_progress.py index a05577fb..cfe07315 100644 --- a/tests/docs_src/test_progress.py +++ b/tests/docs_src/test_progress.py @@ -43,7 +43,7 @@ async def test_each_report_becomes_one_callback_invocation_in_order() -> None: async def test_over_a_wire_dispatcher_callbacks_race_the_result() -> None: - """The `!!! info`: only the in-memory connection runs the callback inline. + """The `!!! info`: only the in-process connection runs the callback inline. On a wire dispatcher (`mode="legacy"` here) each progress notification starts its own task, so `call_tool` can return while a slow callback is still running. The callbacks below block on an diff --git a/tests/docs_src/test_protocol_versions.py b/tests/docs_src/test_protocol_versions.py index 06b8a3a0..691b6cc2 100644 --- a/tests/docs_src/test_protocol_versions.py +++ b/tests/docs_src/test_protocol_versions.py @@ -1,11 +1,16 @@ -"""`docs/protocol-versions.md`: every claim the page makes, proved against the real SDK.""" +"""`docs/protocol-versions.md`: every claim the page makes, proved against the real SDK. + +The page's snippets are URL clients for the Bookshop `server.py` from The Client page +(`docs_src/client/tutorial001.py`). These tests open the same connections in-process +against that server object, so each `mode=` is exercised without a port. +""" import re import pytest from mcp_types import SERVER_INFO_META_KEY, DiscoverResult, Implementation, ServerCapabilities -from docs_src.protocol_versions import tutorial001, tutorial002, tutorial003, tutorial004 +from docs_src.client import tutorial001 as bookshop from mcp import Client # See test_index.py for why this is a per-module mark and not a conftest hook. @@ -13,8 +18,9 @@ pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDepre async def test_auto_lands_on_the_modern_version() -> None: - """tutorial001: the default `mode="auto"` probes `server/discover` and adopts the result.""" - async with Client(tutorial001.mcp) as client: + """tutorial001's connection, in-process: the default `mode="auto"` probes `server/discover` + and adopts the result, so the page's `2026-07-28` output block is what it prints.""" + async with Client(bookshop.mcp) as client: assert client.protocol_version == "2026-07-28" assert client.server_info is not None assert client.server_info.name == "Bookshop" @@ -23,8 +29,9 @@ async def test_auto_lands_on_the_modern_version() -> None: async def test_legacy_forces_the_initialize_handshake() -> None: - """tutorial002: `mode="legacy"` runs `initialize` against the very same server.""" - async with Client(tutorial002.mcp, mode="legacy") as client: + """tutorial002's connection, in-process: `mode="legacy"` runs `initialize` against the very + same server and lands on the newest handshake-era version.""" + async with Client(bookshop.mcp, mode="legacy") as client: assert client.protocol_version == "2025-11-25" assert client.server_info is not None assert client.server_info.name == "Bookshop" @@ -33,14 +40,16 @@ async def test_legacy_forces_the_initialize_handshake() -> None: async def test_version_pin_sends_nothing_and_knows_nothing() -> None: - """tutorial003: a pin adopts the version locally; `server_info` is None and capabilities are blank.""" - async with Client(tutorial003.mcp, mode="2026-07-28") as client: + """tutorial003's connection, in-process: a pin adopts the version locally, so `server_info` + is None and every capability is blank, yet a tool call still round-trips.""" + async with Client(bookshop.mcp, mode="2026-07-28") as client: assert client.protocol_version == "2026-07-28" + assert client.session.initialize_result is None # The `!!! check` fence is the literal `print(client.server_info)` output: None. assert client.server_info is None assert client.server_capabilities == ServerCapabilities() result = await client.call_tool("search_books", {"query": "dune"}) - assert result.structured_content == {"result": "Found 3 books matching 'dune'."} + assert result.structured_content == {"result": "Found 3 books matching 'dune' (showing up to 10)."} def test_handshake_era_version_is_not_a_valid_pin() -> None: @@ -52,18 +61,20 @@ def test_handshake_era_version_is_not_a_valid_pin() -> None: "got '2025-06-18' ('2025-06-18' is a handshake-era version; use mode='legacy')" ), ): - Client(tutorial003.mcp, mode="2025-06-18") + Client(bookshop.mcp, mode="2025-06-18") async def test_prior_discover_round_trips() -> None: - """tutorial004: save `discover_result`, reconnect with it, and the identity comes back.""" - async with Client(tutorial004.mcp) as client: + """tutorial004's flow, driven in-process against the Bookshop server: save `discover_result`, + reconnect pinned with it, and the identity comes back without any negotiation.""" + async with Client(bookshop.mcp) as client: saved = client.session.discover_result assert saved is not None assert saved.supported_versions == ["2026-07-28"] - async with Client(tutorial004.mcp, mode="2026-07-28", prior_discover=saved) as client: + async with Client(bookshop.mcp, mode="2026-07-28", prior_discover=saved) as client: assert client.protocol_version == "2026-07-28" + assert client.session.discover_result is saved assert client.server_info is not None assert client.server_info.name == "Bookshop" assert client.server_capabilities.tools is not None @@ -71,14 +82,14 @@ async def test_prior_discover_round_trips() -> None: async def test_discover_result_survives_json() -> None: """`DiscoverResult` is a Pydantic model: dump it to JSON, validate it back, reconnect with it.""" - async with Client(tutorial004.mcp) as client: + async with Client(bookshop.mcp) as client: saved = client.session.discover_result assert saved is not None restored = DiscoverResult.model_validate_json(saved.model_dump_json()) assert restored == saved - async with Client(tutorial004.mcp, mode="2026-07-28", prior_discover=restored) as client: + async with Client(bookshop.mcp, mode="2026-07-28", prior_discover=restored) as client: assert client.server_info is not None assert client.server_info.name == "Bookshop" @@ -94,9 +105,9 @@ async def test_prior_discover_is_ignored_unless_mode_is_a_pin() -> None: ) }, ) - async with Client(tutorial004.mcp, prior_discover=stale) as client: + async with Client(bookshop.mcp, prior_discover=stale) as client: assert client.server_info is not None assert client.server_info.name == "Bookshop" - async with Client(tutorial004.mcp, mode="legacy", prior_discover=stale) as client: + async with Client(bookshop.mcp, mode="legacy", prior_discover=stale) as client: assert client.session.discover_result is None assert client.protocol_version == "2025-11-25" From 08a3bc8eaf5bb69a6cb05ac708a86f5325977c20 Mon Sep 17 00:00:00 2001 From: Max <224885523+maxisbey@users.noreply.github.com> Date: Sun, 6 Sep 2026 14:44:38 +0100 Subject: [PATCH 17/20] docs: ask for AI disclosure on comments too (#3459) --- CONTRIBUTING.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 57c2b14a..a5864c63 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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. From fd66270926b98b28e6cca21b35ec5ba0e4855892 Mon Sep 17 00:00:00 2001 From: Max <224885523+maxisbey@users.noreply.github.com> Date: Mon, 7 Sep 2026 13:35:53 +0100 Subject: [PATCH 18/20] docs: refresh translations, and translate pages in parallel (#3458) --- i18n/README.md | 4 +- i18n/de/pages/advanced/apps.md | 28 +- i18n/de/pages/advanced/extensions.md | 51 ++-- i18n/de/pages/advanced/low-level-server.md | 20 +- i18n/de/pages/advanced/pagination.md | 20 +- i18n/de/pages/client/caching.md | 22 +- i18n/de/pages/client/callbacks.md | 6 +- i18n/de/pages/client/index.md | 48 ++-- i18n/de/pages/client/oauth-clients.md | 13 +- i18n/de/pages/client/session-groups.md | 4 +- i18n/de/pages/client/transports.md | 63 +++-- i18n/de/pages/deprecated.md | 10 +- i18n/de/pages/get-started/first-steps.md | 37 ++- i18n/de/pages/get-started/testing.md | 10 +- i18n/de/pages/handlers/multi-round-trip.md | 4 +- i18n/de/pages/handlers/progress.md | 31 +- i18n/de/pages/protocol-versions.md | 25 +- i18n/de/pages/run/asgi.md | 6 +- i18n/de/pages/run/authorization.md | 16 +- i18n/de/pages/run/deploy.md | 19 +- i18n/de/pages/run/index.md | 7 +- i18n/de/pages/run/legacy-clients.md | 59 +++- i18n/de/pages/troubleshooting.md | 42 ++- i18n/de/pages/whats-new.md | 18 +- i18n/es/pages/advanced/apps.md | 33 ++- i18n/es/pages/advanced/extensions.md | 62 ++-- i18n/es/pages/advanced/low-level-server.md | 20 +- i18n/es/pages/advanced/pagination.md | 14 +- i18n/es/pages/client/caching.md | 22 +- i18n/es/pages/client/callbacks.md | 6 +- i18n/es/pages/client/index.md | 48 ++-- i18n/es/pages/client/oauth-clients.md | 13 +- i18n/es/pages/client/session-groups.md | 4 +- i18n/es/pages/client/transports.md | 63 +++-- i18n/es/pages/deprecated.md | 10 +- i18n/es/pages/get-started/first-steps.md | 37 ++- i18n/es/pages/get-started/testing.md | 10 +- i18n/es/pages/handlers/multi-round-trip.md | 4 +- i18n/es/pages/handlers/progress.md | 31 +- i18n/es/pages/protocol-versions.md | 25 +- i18n/es/pages/run/asgi.md | 6 +- i18n/es/pages/run/authorization.md | 50 ++-- i18n/es/pages/run/deploy.md | 19 +- i18n/es/pages/run/index.md | 7 +- i18n/es/pages/run/legacy-clients.md | 55 +++- i18n/es/pages/troubleshooting.md | 40 ++- i18n/es/pages/whats-new.md | 16 +- i18n/fr/pages/advanced/apps.md | 30 +- i18n/fr/pages/advanced/extensions.md | 51 ++-- i18n/fr/pages/advanced/low-level-server.md | 20 +- i18n/fr/pages/advanced/pagination.md | 14 +- i18n/fr/pages/client/caching.md | 22 +- i18n/fr/pages/client/callbacks.md | 6 +- i18n/fr/pages/client/index.md | 48 ++-- i18n/fr/pages/client/oauth-clients.md | 13 +- i18n/fr/pages/client/session-groups.md | 4 +- i18n/fr/pages/client/transports.md | 63 +++-- i18n/fr/pages/deprecated.md | 10 +- i18n/fr/pages/get-started/first-steps.md | 35 ++- i18n/fr/pages/get-started/testing.md | 12 +- i18n/fr/pages/handlers/multi-round-trip.md | 4 +- i18n/fr/pages/handlers/progress.md | 22 +- i18n/fr/pages/protocol-versions.md | 25 +- i18n/fr/pages/run/asgi.md | 6 +- i18n/fr/pages/run/authorization.md | 14 +- i18n/fr/pages/run/deploy.md | 19 +- i18n/fr/pages/run/index.md | 7 +- i18n/fr/pages/run/legacy-clients.md | 57 +++- i18n/fr/pages/troubleshooting.md | 40 ++- i18n/fr/pages/whats-new.md | 16 +- i18n/hi/pages/advanced/apps.md | 32 ++- i18n/hi/pages/advanced/extensions.md | 51 ++-- i18n/hi/pages/advanced/low-level-server.md | 36 ++- i18n/hi/pages/advanced/pagination.md | 14 +- i18n/hi/pages/client/caching.md | 22 +- i18n/hi/pages/client/callbacks.md | 8 +- i18n/hi/pages/client/index.md | 48 ++-- i18n/hi/pages/client/oauth-clients.md | 13 +- i18n/hi/pages/client/session-groups.md | 4 +- i18n/hi/pages/client/transports.md | 63 +++-- i18n/hi/pages/deprecated.md | 10 +- i18n/hi/pages/get-started/first-steps.md | 35 ++- i18n/hi/pages/get-started/testing.md | 8 +- i18n/hi/pages/handlers/multi-round-trip.md | 4 +- i18n/hi/pages/handlers/progress.md | 34 +-- i18n/hi/pages/protocol-versions.md | 27 +- i18n/hi/pages/run/asgi.md | 6 +- i18n/hi/pages/run/authorization.md | 14 +- i18n/hi/pages/run/deploy.md | 19 +- i18n/hi/pages/run/index.md | 17 +- i18n/hi/pages/run/legacy-clients.md | 54 +++- i18n/hi/pages/troubleshooting.md | 40 ++- i18n/hi/pages/whats-new.md | 46 +-- i18n/ja/pages/advanced/apps.md | 28 +- i18n/ja/pages/advanced/extensions.md | 51 ++-- i18n/ja/pages/advanced/low-level-server.md | 20 +- i18n/ja/pages/advanced/pagination.md | 14 +- i18n/ja/pages/client/caching.md | 22 +- i18n/ja/pages/client/callbacks.md | 6 +- i18n/ja/pages/client/index.md | 50 ++-- i18n/ja/pages/client/oauth-clients.md | 13 +- i18n/ja/pages/client/session-groups.md | 4 +- i18n/ja/pages/client/transports.md | 60 ++-- i18n/ja/pages/deprecated.md | 10 +- i18n/ja/pages/get-started/first-steps.md | 32 +-- i18n/ja/pages/get-started/testing.md | 10 +- i18n/ja/pages/handlers/multi-round-trip.md | 4 +- i18n/ja/pages/handlers/progress.md | 22 +- i18n/ja/pages/protocol-versions.md | 20 +- i18n/ja/pages/run/asgi.md | 6 +- i18n/ja/pages/run/authorization.md | 14 +- i18n/ja/pages/run/deploy.md | 19 +- i18n/ja/pages/run/index.md | 4 +- i18n/ja/pages/run/legacy-clients.md | 45 ++- i18n/ja/pages/troubleshooting.md | 40 ++- i18n/ja/pages/whats-new.md | 16 +- i18n/ko/pages/advanced/apps.md | 28 +- i18n/ko/pages/advanced/extensions.md | 51 ++-- i18n/ko/pages/advanced/low-level-server.md | 20 +- i18n/ko/pages/advanced/pagination.md | 14 +- i18n/ko/pages/client/caching.md | 22 +- i18n/ko/pages/client/callbacks.md | 6 +- i18n/ko/pages/client/index.md | 48 ++-- i18n/ko/pages/client/oauth-clients.md | 15 +- i18n/ko/pages/client/session-groups.md | 4 +- i18n/ko/pages/client/transports.md | 63 +++-- i18n/ko/pages/deprecated.md | 10 +- i18n/ko/pages/get-started/first-steps.md | 35 ++- i18n/ko/pages/get-started/testing.md | 16 +- i18n/ko/pages/handlers/multi-round-trip.md | 4 +- i18n/ko/pages/handlers/progress.md | 30 +- i18n/ko/pages/protocol-versions.md | 25 +- i18n/ko/pages/run/asgi.md | 6 +- i18n/ko/pages/run/authorization.md | 14 +- i18n/ko/pages/run/deploy.md | 19 +- i18n/ko/pages/run/index.md | 7 +- i18n/ko/pages/run/legacy-clients.md | 45 ++- i18n/ko/pages/troubleshooting.md | 42 ++- i18n/ko/pages/whats-new.md | 16 +- i18n/pt/pages/advanced/apps.md | 31 +- i18n/pt/pages/advanced/extensions.md | 51 ++-- i18n/pt/pages/advanced/low-level-server.md | 22 +- i18n/pt/pages/advanced/pagination.md | 14 +- i18n/pt/pages/client/caching.md | 22 +- i18n/pt/pages/client/callbacks.md | 6 +- i18n/pt/pages/client/index.md | 48 ++-- i18n/pt/pages/client/oauth-clients.md | 13 +- i18n/pt/pages/client/session-groups.md | 4 +- i18n/pt/pages/client/transports.md | 63 +++-- i18n/pt/pages/deprecated.md | 10 +- i18n/pt/pages/get-started/first-steps.md | 35 ++- i18n/pt/pages/get-started/testing.md | 8 +- i18n/pt/pages/handlers/multi-round-trip.md | 4 +- i18n/pt/pages/handlers/progress.md | 31 +- i18n/pt/pages/protocol-versions.md | 25 +- i18n/pt/pages/run/asgi.md | 6 +- i18n/pt/pages/run/authorization.md | 14 +- i18n/pt/pages/run/deploy.md | 19 +- i18n/pt/pages/run/index.md | 7 +- i18n/pt/pages/run/legacy-clients.md | 54 +++- i18n/pt/pages/troubleshooting.md | 40 ++- i18n/pt/pages/whats-new.md | 16 +- i18n/ru/pages/advanced/apps.md | 28 +- i18n/ru/pages/advanced/extensions.md | 51 ++-- i18n/ru/pages/advanced/low-level-server.md | 20 +- i18n/ru/pages/advanced/pagination.md | 14 +- i18n/ru/pages/client/caching.md | 22 +- i18n/ru/pages/client/callbacks.md | 6 +- i18n/ru/pages/client/index.md | 48 ++-- i18n/ru/pages/client/oauth-clients.md | 13 +- i18n/ru/pages/client/session-groups.md | 4 +- i18n/ru/pages/client/transports.md | 63 +++-- i18n/ru/pages/deprecated.md | 12 +- i18n/ru/pages/get-started/first-steps.md | 36 ++- i18n/ru/pages/get-started/testing.md | 8 +- i18n/ru/pages/handlers/multi-round-trip.md | 4 +- i18n/ru/pages/handlers/progress.md | 33 +-- i18n/ru/pages/protocol-versions.md | 25 +- i18n/ru/pages/run/asgi.md | 8 +- i18n/ru/pages/run/authorization.md | 82 +++--- i18n/ru/pages/run/deploy.md | 19 +- i18n/ru/pages/run/index.md | 7 +- i18n/ru/pages/run/legacy-clients.md | 54 +++- i18n/ru/pages/troubleshooting.md | 40 ++- i18n/ru/pages/whats-new.md | 16 +- i18n/tr/pages/advanced/apps.md | 28 +- i18n/tr/pages/advanced/extensions.md | 51 ++-- i18n/tr/pages/advanced/low-level-server.md | 20 +- i18n/tr/pages/advanced/pagination.md | 14 +- i18n/tr/pages/client/caching.md | 24 +- i18n/tr/pages/client/callbacks.md | 6 +- i18n/tr/pages/client/index.md | 48 ++-- i18n/tr/pages/client/oauth-clients.md | 15 +- i18n/tr/pages/client/session-groups.md | 4 +- i18n/tr/pages/client/transports.md | 63 +++-- i18n/tr/pages/deprecated.md | 10 +- i18n/tr/pages/get-started/first-steps.md | 37 ++- i18n/tr/pages/get-started/testing.md | 8 +- i18n/tr/pages/handlers/multi-round-trip.md | 4 +- i18n/tr/pages/handlers/progress.md | 31 +- i18n/tr/pages/protocol-versions.md | 25 +- i18n/tr/pages/run/asgi.md | 6 +- i18n/tr/pages/run/authorization.md | 14 +- i18n/tr/pages/run/deploy.md | 19 +- i18n/tr/pages/run/index.md | 7 +- i18n/tr/pages/run/legacy-clients.md | 46 ++- i18n/tr/pages/troubleshooting.md | 40 ++- i18n/tr/pages/whats-new.md | 16 +- i18n/uk/pages/advanced/apps.md | 30 +- i18n/uk/pages/advanced/extensions.md | 51 ++-- i18n/uk/pages/advanced/low-level-server.md | 20 +- i18n/uk/pages/advanced/pagination.md | 14 +- i18n/uk/pages/client/caching.md | 22 +- i18n/uk/pages/client/callbacks.md | 6 +- i18n/uk/pages/client/index.md | 48 ++-- i18n/uk/pages/client/oauth-clients.md | 13 +- i18n/uk/pages/client/session-groups.md | 4 +- i18n/uk/pages/client/transports.md | 63 +++-- i18n/uk/pages/deprecated.md | 12 +- i18n/uk/pages/get-started/first-steps.md | 35 ++- i18n/uk/pages/get-started/testing.md | 12 +- i18n/uk/pages/handlers/multi-round-trip.md | 4 +- i18n/uk/pages/handlers/progress.md | 31 +- i18n/uk/pages/protocol-versions.md | 25 +- i18n/uk/pages/run/asgi.md | 6 +- i18n/uk/pages/run/authorization.md | 84 +++--- i18n/uk/pages/run/deploy.md | 19 +- i18n/uk/pages/run/index.md | 7 +- i18n/uk/pages/run/legacy-clients.md | 57 +++- i18n/uk/pages/troubleshooting.md | 40 ++- i18n/uk/pages/whats-new.md | 16 +- i18n/zh-hant/pages/advanced/apps.md | 28 +- i18n/zh-hant/pages/advanced/extensions.md | 51 ++-- .../pages/advanced/low-level-server.md | 20 +- i18n/zh-hant/pages/advanced/pagination.md | 14 +- i18n/zh-hant/pages/client/caching.md | 22 +- i18n/zh-hant/pages/client/callbacks.md | 6 +- i18n/zh-hant/pages/client/index.md | 48 ++-- i18n/zh-hant/pages/client/oauth-clients.md | 13 +- i18n/zh-hant/pages/client/session-groups.md | 4 +- i18n/zh-hant/pages/client/transports.md | 60 ++-- i18n/zh-hant/pages/deprecated.md | 10 +- i18n/zh-hant/pages/get-started/first-steps.md | 32 +-- i18n/zh-hant/pages/get-started/testing.md | 8 +- .../pages/handlers/multi-round-trip.md | 4 +- i18n/zh-hant/pages/handlers/progress.md | 22 +- i18n/zh-hant/pages/protocol-versions.md | 20 +- i18n/zh-hant/pages/run/asgi.md | 6 +- i18n/zh-hant/pages/run/authorization.md | 14 +- i18n/zh-hant/pages/run/deploy.md | 19 +- i18n/zh-hant/pages/run/index.md | 4 +- i18n/zh-hant/pages/run/legacy-clients.md | 45 ++- i18n/zh-hant/pages/troubleshooting.md | 40 ++- i18n/zh-hant/pages/whats-new.md | 18 +- i18n/zh/pages/advanced/apps.md | 28 +- i18n/zh/pages/advanced/extensions.md | 51 ++-- i18n/zh/pages/advanced/low-level-server.md | 20 +- i18n/zh/pages/advanced/pagination.md | 14 +- i18n/zh/pages/client/caching.md | 24 +- i18n/zh/pages/client/callbacks.md | 6 +- i18n/zh/pages/client/index.md | 48 ++-- i18n/zh/pages/client/oauth-clients.md | 13 +- i18n/zh/pages/client/session-groups.md | 4 +- i18n/zh/pages/client/transports.md | 65 +++-- i18n/zh/pages/deprecated.md | 10 +- i18n/zh/pages/get-started/first-steps.md | 32 +-- i18n/zh/pages/get-started/testing.md | 8 +- i18n/zh/pages/handlers/multi-round-trip.md | 4 +- i18n/zh/pages/handlers/progress.md | 22 +- i18n/zh/pages/protocol-versions.md | 20 +- i18n/zh/pages/run/asgi.md | 6 +- i18n/zh/pages/run/authorization.md | 14 +- i18n/zh/pages/run/deploy.md | 19 +- i18n/zh/pages/run/index.md | 4 +- i18n/zh/pages/run/legacy-clients.md | 45 ++- i18n/zh/pages/troubleshooting.md | 40 ++- i18n/zh/pages/whats-new.md | 16 +- scripts/docs/translations.py | 148 ++++++++-- tests/docs/test_translations.py | 267 +++++++++++++++--- 279 files changed, 4821 insertions(+), 2277 deletions(-) diff --git a/i18n/README.md b/i18n/README.md index 126f8bee..948e95cc 100644 --- a/i18n/README.md +++ b/i18n/README.md @@ -11,10 +11,10 @@ The English pages under `docs/` are the source. This directory holds what steers ```text uv run --frozen python scripts/docs/translations.py status [--lang CODE] -uv run --frozen --group translate python scripts/docs/translations.py translate --lang CODE [--pages PATH ...] +uv run --frozen --group translate python scripts/docs/translations.py translate [--lang CODE ...] [--pages PATH ...] [--jobs N] uv run --frozen python scripts/docs/translations.py stage [--lang CODE] ``` -`status` is offline: per language it lists missing, outdated (with the sections that changed), current and removable pages (translations whose English page is gone — `git rm` them). `translate` calls the Claude API (`ANTHROPIC_API_KEY` in the environment; the registry's model, or `DOCS_TRANSLATE_MODEL` to trial another) for the missing and outdated pages, retranslating only the English sections that changed and keeping the rest byte for byte; `--pages` instead re-translates exactly the named pages from scratch, which is also how a glossary or instructions change reaches existing pages (each generated page records the English section hashes it reflects, so editing those inputs invalidates nothing). `stage` assembles the tree each language site is built from (every language's, or one with `--lang`): each generated page exactly as it was generated, under an "outdated" notice linking the current English page when the English has changed since, and the English page where nothing was generated yet; `scripts/docs/build.sh` runs it before building them. Commit the generated pages in an ordinary pull request. +`status` is offline: per language it lists missing, outdated (with the sections that changed), current and removable pages (translations whose English page is gone — `git rm` them). `translate` calls the Claude API (`ANTHROPIC_API_KEY` in the environment; the registry's model, or `DOCS_TRANSLATE_MODEL` to trial another) for the missing and outdated pages of every language (or just the `--lang` ones), several pages at a time (`--jobs`, default 8; each page is its own request, so this changes how long the run takes, not what the model sees), retranslating only the English sections that changed and keeping the rest byte for byte; `--pages` instead re-translates exactly the named pages from scratch (in every language unless `--lang` narrows it), which is also how a glossary or instructions change reaches existing pages (each generated page records the English section hashes it reflects, so editing those inputs invalidates nothing). `stage` assembles the tree each language site is built from (every language's, or one with `--lang`): each generated page exactly as it was generated, under an "outdated" notice linking the current English page when the English has changed since, and the English page where nothing was generated yet; `scripts/docs/build.sh` runs it before building them. Commit the generated pages in an ordinary pull request. To add a language, add an entry to `languages.yml`, write `/instructions.md` (the sections the `pt` file has) and `/glossary.json`, then run `translate --lang `. diff --git a/i18n/de/pages/advanced/apps.md b/i18n/de/pages/advanced/apps.md index 2df9303c..266de049 100644 --- a/i18n/de/pages/advanced/apps.md +++ b/i18n/de/pages/advanced/apps.md @@ -1,6 +1,6 @@ --- translation: - sections: [0355618e5f4d5fe4, 1821eaf50f2d0b64, 82e0b28ebd3abf5a, 8ac39614c094f2d0, dab6ff945501ab2a, bd5565c3b2d4f959, 96819ce3d63a0487] + sections: [0355618e5f4d5fe4, 0fefa31fb7c7b585, 5c53e7487c9c70cc, 8ac39614c094f2d0, dab6ff945501ab2a, bd5565c3b2d4f959, 96819ce3d63a0487] tool: 1 --- # MCP Apps {#mcp-apps} @@ -18,7 +18,7 @@ Das SDK liefert das als eingebaute Extension `Apps` (`io.modelcontextprotocol/ui ## Eine Uhr mit Gesicht {#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" ``` @@ -39,11 +39,31 @@ Nicht jeder Client rendert Apps. Die Spezifikation sagt unverblümt, was das fü Das Modell liest `content`; der iframe ist für Menschen. Ein UI-fähiger Host füttert das Modell trotzdem mit dem Textergebnis, und ein reiner Text-Client bekommt *nur* das. Das kanonische Muster ist also: ein Tool, zwei Antworten. Sieh dir `get_time` noch einmal an: -```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)` ist nur dann `True`, wenn der Client die Extension `io.modelcontextprotocol/ui` deklariert **und** `text/html;profile=mcp-app` in seinen `mimeTypes`-Einstellungen aufgeführt hat. Das Feld ist Pflicht, ein Client, der es weglässt, zählt also nicht. Genau das deklariert `main()` in derselben Datei: die Client-Hälfte der Aushandlung – und die reichhaltige Antwort kommt zurück. +`client_supports_apps(ctx)` ist nur dann `True`, wenn der Client die Extension `io.modelcontextprotocol/ui` deklariert **und** `text/html;profile=mcp-app` in seinen `mimeTypes`-Einstellungen aufgeführt hat. Das Feld ist Pflicht, ein Client, der es weglässt, zählt also nicht. Hier ist die Client-Hälfte der Aushandlung: + +```python title="client.py" hl_lines="8 12" +--8<-- "docs_src/apps/tutorial001_client.py" +``` + +Stelle `server.py` über HTTP bereit und starte dann den Client in einem zweiten Terminal: + +```console +uv run mcp run server.py --transport streamable-http +``` + +```console +python client.py +``` + +```text +2026-06-26T12:00:00Z +``` + +Die reichhaltige Antwort kam zurück. Lass `extensions=[APPS_SUPPORT]` im `Client`-Aufruf weg, und dasselbe Programm gibt stattdessen `The time is 2026-06-26T12:00:00Z.` aus – das ist alles, was ein reiner Text-Client je zu sehen bekommt. !!! warning Gib niemals einen Platzhalter wie `"[Rendered UI]"` als einzigen Inhalt zurück. Wenn der Fallback-Text nutzlos ist, ist das Tool für jeden reinen Text-Client und für das Modell selbst nutzlos. Schreib den Satz. diff --git a/i18n/de/pages/advanced/extensions.md b/i18n/de/pages/advanced/extensions.md index 79745141..c47c7ae3 100644 --- a/i18n/de/pages/advanced/extensions.md +++ b/i18n/de/pages/advanced/extensions.md @@ -1,6 +1,6 @@ --- translation: - sections: [05891e7cc1938a13, b3c01a6af28c51ee, 7ffc91f5e38bdfe0, 717d3f235a8333a7, f471a13b2fe5d737, ed6af2df4b656dff] + sections: [05891e7cc1938a13, b3c01a6af28c51ee, 6eba6ce094f4417e, 125f28588c85dd7b, ddd8969b698ca11c, ed6af2df4b656dff] tool: 1 --- # Extensions {#extensions} @@ -49,7 +49,7 @@ Nimm als Präfix eine Domain, die du kontrollierst. `io.modelcontextprotocol/*` Die kleinste nützliche Extension besteht aus einem Tool und einer 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" ``` @@ -57,17 +57,23 @@ Die kleinste nützliche Extension besteht aus einem Tool und einer Settings-Map: * `settings()` ist der Wert, der unter `capabilities.extensions["com.example/stamps"]` angekündigt wird. Gib `{}` zurück (den Standardwert), um die Extension ohne Settings anzukündigen. * Die Extension bekommt den Server nie in die Hand. Sie deklariert ihre Beiträge als Daten; `MCPServer` verarbeitet sie. Es gibt kein `self.server`, das sie verändern könnte. -Und `main()` ist der Beweis, ein In-Memory-Client direkt gegen `mcp`: +Stelle sie über HTTP bereit, und ein Client liefert den Beweis: -```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" +``` + +Jede `server.py` auf dieser Seite wird mit diesem Befehl bereitgestellt, und jede `client.py` läuft daneben mit `python client.py` aus einem zweiten Terminal. + ### Eigene Methoden bedienen {#serving-your-own-methods} Eine Extension kann **neue Request-Methoden** registrieren: eigene Verben, bedient neben denen der Spezifikation: -```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" ``` @@ -83,14 +89,15 @@ Methoden sind **strikt additiv**. Das SDK erzwingt das bei der Konstruktion, nic ### Die Client-Seite {#the-client-side} -Das `main()` derselben Datei ist die ganze Client-Geschichte, beide Hälften davon: +Der Client ist ein eigenes Programm und trägt beide Hälften der Client-Geschichte: -```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)])` deklariert die Extension. Die Deklarationen werden zu `ClientCapabilities.extensions`: Auf einer 2026-07-28-Verbindung reist die Map im `_meta`-Umschlag jedes einzelnen Requests, der Server sieht sie also bei **jedem** Request; auf einer Legacy-Verbindung reist sie mit dem `initialize`-Handshake. Dem Server-Code ist das egal: `require_client_extension(ctx, ...)` und `ctx.session.check_client_capability(...)` lesen auf beiden Pfaden die richtige Quelle. * Vendor-Methoden steigen eine Schicht tiefer zu `client.session.send_request(...)` hinab; `Client` bekommt nur für Verben der Spezifikation eigene Methoden. `send_request` akzeptiert jede `Request`-Unterklasse, der Vendor-Request geht also unverändert durch. +* `SearchRequest` und die beiden Models, die er trägt, sind der Vertrag der Extension auf der Leitung, also deklariert der Client sie für sich selbst. Eine veröffentlichte Extension würde sie in einem Paket ausliefern, das beide Seiten importieren. ### `tools/call` abfangen {#intercepting-toolscall} @@ -109,12 +116,18 @@ Der Hook umhüllt `tools/call` und sonst nichts. Für alles, was jede Nachricht ## Eine Client-Extension verwenden {#using-a-client-extension} -Eine **Client-Extension** ist derselbe Vertrag von der konsumierenden Seite: ein Bündel clientseitigen Verhaltens hinter einem einzigen Identifier. Übergib Instanzen an `Client(extensions=[...])` und rufe Tools ganz normal auf: +Eine **Client-Extension** ist derselbe Vertrag von der konsumierenden Seite: ein Bündel clientseitigen Verhaltens hinter einem einzigen Identifier. Der Server hier beantwortet `buy` mit einem einzulösenden Beleg statt mit der Ware, und das nur für einen Client, der die Extension deklariert hat: -```python title="client.py" hl_lines="66-68" +```python title="server.py" hl_lines="22-25" --8<-- "docs_src/extensions/tutorial006.py" ``` +Übergib auf dem Client Instanzen an `Client(extensions=[...])` und rufe Tools ganz normal auf: + +```python title="client.py" hl_lines="33-35" +--8<-- "docs_src/extensions/tutorial006_client.py" +``` + `call_tool("buy", ...)` gibt ein gewöhnliches `CallToolResult` zurück, wie jeder andere Aufruf. Was die Extension geändert hat: Der Server darf `buy` jetzt mit einer `receipt`-**Ergebnisform** statt mit einem endgültigen Ergebnis beantworten, und `Receipts` bringt sie zu Ende (hier, indem sie den Beleg mit einem Folgeaufruf einlöst), bevor `call_tool` zurückkehrt. An der Aufrufstelle bewegt sich nichts. Lass die Extension weg, und nichts davon existiert: Die Schranke des Servers weist einen Client ab, der sie nicht deklariert hat (Fehler -32021), und eine beanspruchte Form von einem Server, der die Schranke überspringt, fällt durch die Validierung, genau wie die Spezifikation es für einen unbekannten `resultType` verlangt. Standardmäßig aus, an beiden Enden der Leitung. @@ -124,15 +137,15 @@ Um einen Identifier **ohne** clientseitiges Verhalten anzukündigen (der Server ```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")]) ``` ## Eine Client-Extension schreiben {#writing-a-client-extension} Leite von `ClientExtension` ab und überschreibe nur, was du brauchst. Drei Arten von Beiträgen, jede mit einer Standardimplementierung: `settings()`, `claims()` und `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" ``` * Der Identifier folgt derselben Grammatik wie auf dem Server und wird validiert, wenn die Klasse definiert wird. @@ -153,12 +166,18 @@ Zwei stille Regeln. Claims sind nur auf 2026-07-28-Verbindungen aktiv, und die C ### Extension-Verben {#extension-verbs} -Die eigenen Request-Methoden einer Extension brauchen keine clientseitige Registrierung. Ein Vendor-Request-Typ leitet von `mcp.types.Request` ab und geht durch `client.session.send_request`, wie in [Eigene Methoden bedienen](#serving-your-own-methods). Eine Ergänzung: Wenn ein Params-Schlüssel im `Mcp-Name`-Header mitreisen muss (Extension-Spezifikationen wie Tasks verlangen das für ihre Verben), deklariert der Request-Typ `name_param`: +Die eigenen Request-Methoden einer Extension brauchen keine clientseitige Registrierung. Ein Vendor-Request-Typ leitet von `mcp.types.Request` ab und geht durch `client.session.send_request`, wie in [Eigene Methoden bedienen](#serving-your-own-methods). Nimm einen Server, dessen Extension ein einziges Verb zu einem benannten Job bedient: -```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" ``` +Eine Ergänzung auf dem Client: Wenn ein Params-Schlüssel im `Mcp-Name`-Header mitreisen muss (Extension-Spezifikationen wie Tasks verlangen das für ihre Verben), deklariert der Request-Typ `name_param`: + +```python title="client.py" hl_lines="20-23 28-29" +--8<-- "docs_src/extensions/tutorial007_client.py" +``` + Die Session spiegelt `params["jobId"]` auf jedem Sendepfad in `Mcp-Name`, und ein fehlender Wert scheitert laut, statt einen erforderlichen Header stillschweigend wegzulassen. ## Was eine Extension nicht kann {#what-an-extension-cannot-do} diff --git a/i18n/de/pages/advanced/low-level-server.md b/i18n/de/pages/advanced/low-level-server.md index a84bc12e..0158d393 100644 --- a/i18n/de/pages/advanced/low-level-server.md +++ b/i18n/de/pages/advanced/low-level-server.md @@ -1,6 +1,6 @@ --- translation: - sections: [2c79b6338e09b7ac, 7edc43b3fae11314, 1086e77ce561cd7f, a3f71823df5efc31, 9fc7109f72201cae, d50fe7faead8cf68, 7bf25983df655b66, 6330e1f4c6029683, 2f1749c8c133fa1c, 8db7116fc8ddd0ee, ebc33704fbd74262, cd0e9c933350390e] + sections: [2c79b6338e09b7ac, 9d5d10a5f0405d0a, 1086e77ce561cd7f, a3f71823df5efc31, 9fc7109f72201cae, d50fe7faead8cf68, 7bf25983df655b66, 6330e1f4c6029683, 2f1749c8c133fa1c, 8db7116fc8ddd0ee, ebc33704fbd74262, 0fde3bcea081ba3a] tool: 1 --- # Der Low-Level-Server {#the-low-level-server} @@ -36,18 +36,22 @@ Drei Dinge haben sich geändert, und sie sind die ganze Low-Level-API: ### Ausprobieren {#try-it} -Hierfür gibt es keinen Inspector: `mcp dev` und `mcp run` akzeptieren nur einen `MCPServer`. Dem In-Memory-`Client` ist das egal; er nimmt einen Low-Level-`Server` genauso wie einen `MCPServer`: +`mcp dev` und `mcp run` akzeptieren nur einen `MCPServer`, also betreibst du diesen hier selbst. Die letzte Zeile von `server.py` baut daraus eine gewöhnliche ASGI-App, und uvicorn führt sie aus: -```python title="main.py" +```console +uvicorn server:app --port 8000 +``` + +Richte den Inspector oder einen beliebigen Client auf `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) @@ -64,6 +68,8 @@ Derselbe Text, den die `@mcp.tool()`-Version erzeugt hat. Zwei ehrliche Untersch * `result.structured_content` ist `None`. Der High-Level-Server verpackt ein `-> str` für dich in `{"result": ...}`; hier baut niemand, was du nicht gebaut hast. * `list_tools` gibt das Schema zurück, das **du** getippt hast, Zeichen für Zeichen. Die High-Level-Version hatte `"title": "Query"` auf jeder Property und ein `"title": "search_booksArguments"` an der Wurzel: Pydantic-Artefakte. Hier unten gilt: Was auf der Leitung ist, hast du dort hingelegt. +In einem Test sparst du dir uvicorn und den Port: `Client(server)` nimmt einen Low-Level-`Server` im selben Prozess genauso entgegen wie einen `MCPServer`, und **[Testen](../get-started/testing.md)** ist genau dieses Muster. + ## Nichts wird für dich geprüft {#nothing-is-checked-for-you} `MCPServer` weist ein fehlerhaftes Argument ab, bevor deine Funktion überhaupt läuft, indem er den Aufruf gegen das generierte Schema validiert (**[Tools](../servers/tools.md)**). @@ -215,4 +221,4 @@ Jeder davon ist eine Idee, für die du jetzt das Vokabular hast; jeder hat seine * `add_request_handler(method, params_type, handler)` bedient jede Methode. `initialize` ist reserviert. * Die Capabilities, die ein `Server` ankündigt, leiten sich davon ab, welche Handler du registriert hast. -`Client(server)` hat beide Server identisch behandelt, weil sie dasselbe Protokoll *sind* – und genau darum geht es. Die nächste Schicht darunter ist gar keine Klasse: Es ist **[Middleware](middleware.md)**. +Der Client hat beide Server identisch behandelt, weil sie dasselbe Protokoll *sind* – und genau darum geht es. Die nächste Schicht darunter ist gar keine Klasse: Es ist **[Middleware](middleware.md)**. diff --git a/i18n/de/pages/advanced/pagination.md b/i18n/de/pages/advanced/pagination.md index a082b294..8b84af10 100644 --- a/i18n/de/pages/advanced/pagination.md +++ b/i18n/de/pages/advanced/pagination.md @@ -1,6 +1,6 @@ --- translation: - sections: [a9aba7a026c7bd85, ed32bda7ba9ae33a, 7e64cc5646abb91f, 22a0129ee78b3c63, d875373c06d8d2f9] + sections: [a9aba7a026c7bd85, 83e2a08b9d46a398, 9fd8a0aa384b3257, 22a0129ee78b3c63, d875373c06d8d2f9] tool: 1 --- # Paginierung {#pagination} @@ -19,21 +19,25 @@ Paginierung ist für den Server gedacht, dessen Ressourcenliste in Wahrheit eine --8<-- "docs_src/pagination/tutorial001.py" ``` -* Auf einem Low-Level-`Server` sind Handler Konstruktorargumente, keine Dekoratoren. `on_list_resources` beantwortet jeden `resources/list`-Request; mehr Verkabelung gibt es nicht. -* Jeder paginierte Handler ist als `params: PaginatedRequestParams | None` typisiert, und das Beispiel akzeptiert beides. Über eine Verbindung übergibt dir das SDK jedoch nie `None` (ein Request ohne `params`-Member erreicht den Handler als Modell mit seinen Standardwerten). Das Signal, auf das es ankommt, ist daher `params.cursor is None`: **von vorne beginnen**. +* Auf einem Low-Level-`Server` sind Handler Konstruktorargumente, keine Dekoratoren. `on_list_resources` beantwortet jeden `resources/list`-Request; das ist schon die ganze Anbindung. +* Jeder paginierte Handler ist als `params: PaginatedRequestParams | None` typisiert, und das Beispiel akzeptiert beides. Über eine Verbindung übergibt dir das SDK allerdings nie `None` (ein Request ohne `params`-Member erreicht den Handler als Modell mit seinen Standardwerten). Das Signal, auf das es ankommt, ist daher `params.cursor is None`: **von vorne beginnen**. * Du entscheidest, was ein Cursor *ist*. Hier ist es ein Offset, als String dargestellt. Ein Zeitstempel, ein Primärschlüssel, ein Base64-Blob: alles, was du beim Herausgeben erzeugen und beim Zurückkommen wiedererkennen kannst. * Mit `next_cursor=None` sagst du „das war die letzte Seite“. Es gibt keine Anzahl, keine Gesamtsumme, kein `has_more`. `None` ist das ganze Signal. !!! tip Eine `PAGE_SIZE` von 10 macht das Beispiel lesbar. Wähle deine pro Endpunkt: Eine Liste - einzeiliger Ressourcen verträgt eine Seite mit 500 Einträgen; eine Liste fetter Prompt-Templates nicht. + einzeiliger Ressourcen verträgt eine Seite mit 500 Einträgen; eine Liste üppiger Prompt-Templates nicht. Der Client hat dabei nichts mitzureden, und das ist Absicht. ### Ausprobieren {#try-it} -`Client(server)` verbindet sich im Speicher mit einem Low-Level-`Server` genau so, wie er sich mit einem `MCPServer` verbindet. +`mcp run` akzeptiert nur einen `MCPServer`, diesen hier stellst du also selbst bereit. Die letzte Zeile von `server.py` baut aus dem `Server` eine gewöhnliche ASGI-App, und uvicorn führt sie aus: -Rufe `list_resources()` ohne Argumente auf. Du bekommst zehn Ressourcen, `book-1` bis `book-10`, und `next_cursor` ist der String `"10"`. +```console +uvicorn server:app --port 8000 +``` + +Richte einen beliebigen Client (**[Der Client](../client/index.md)** oder den Inspector) auf `http://localhost:8000/mcp` und rufe `list_resources()` ohne Argumente auf. Du bekommst zehn Ressourcen, `book-1` bis `book-10`, und `next_cursor` ist der String `"10"`. Gib ihn mit `list_resources(cursor="10")` zurück, und die erste Ressource ist `book-11`, der neue `next_cursor` ist `"20"`. @@ -43,7 +47,7 @@ Die zehnte Seite kommt mit `next_cursor` auf `None` zurück. Fertig. Jede `list_*`-Methode auf `Client` (`list_tools`, `list_resources`, `list_resource_templates`, `list_prompts`) nimmt ein Keyword-Argument `cursor=`. Eine paginierte Liste leerzulesen ist ein einziges `while True`: -```python title="client.py" hl_lines="26-32" +```python title="client.py" hl_lines="9-15" --8<-- "docs_src/pagination/tutorial002.py" ``` @@ -51,7 +55,7 @@ Jede `list_*`-Methode auf `Client` (`list_tools`, `list_resources`, `list_resour * Erweitere die Liste, **bevor** du auf `next_cursor` schaust: Auch die letzte Seite enthält Ressourcen. * `next_cursor is None` ist der Ausstieg. Alles andere geht unverändert direkt zurück in `cursor=`. -Führe sein `main()` aus, und es gibt `100 resources` aus: zehn Seiten zu je zehn, zusammengefügt von einer Schleife, die nie wusste, dass es zehn Seiten waren. +Während uvicorn weiterhin `server.py` ausliefert, starte in einem zweiten Terminal `python client.py`. Es gibt `100 resources` aus: zehn Seiten zu je zehn, zusammengefügt von einer Schleife, die nie wusste, dass es zehn Seiten waren. Das ist dieselbe Schleife, die **[Der Client](../client/index.md)** für jedes `list_*`-Verb zeigt, und sie kostet nichts gegenüber einem Server, der nicht paginiert: `next_cursor` ist schon in der ersten Response `None`, und die Schleife läuft genau einmal. diff --git a/i18n/de/pages/client/caching.md b/i18n/de/pages/client/caching.md index 88602435..43ce2df4 100644 --- a/i18n/de/pages/client/caching.md +++ b/i18n/de/pages/client/caching.md @@ -1,6 +1,6 @@ --- translation: - sections: [9e7b9a1710e5aeba, b74ca4c1d2ddddee, fa8714e61bf90c5a, 04db67a886b7271c, 857690fb8f876800] + sections: [9e7b9a1710e5aeba, 66a2e9acc9101d54, d3d25aa802f5145a, 04db67a886b7271c, 857690fb8f876800] tool: 1 --- # Caching-Hinweise {#caching-hints} @@ -31,7 +31,7 @@ Ohne weitere Konfiguration sagt jedes Ergebnis `ttlMs: 0, cacheScope: "private"` Auf dem Low-Level-`Server` bauen Handler ihre Ergebnisse von Hand, und `ttl_ms` / `cache_scope` sind einfach Felder der Ergebnismodelle. Ein Handler, der sie explizit setzt, gewinnt immer gegen die Map aus dem Konstruktor, Feld für Feld: -```python title="server.py" hl_lines="10 16" +```python title="server.py" hl_lines="11 17" --8<-- "docs_src/caching/tutorial002.py" ``` @@ -45,10 +45,24 @@ Ein Vorbehalt bei paginierten Listen: Das Protokoll verlangt **denselben `cacheS In einer 2026-07-28-Session beachtet `Client` die Hinweise für dich: Er hat einen eingebauten Response-Cache, der standardmäßig aktiv ist. Ein Ergebnis, das mit einem `ttlMs` ankommt, wird gespeichert, und ein identischer Aufruf innerhalb dieser TTL wird ohne Roundtrip aus dem Cache bedient. Ein Ergebnis, das *keinen* Hinweis trägt, wird nicht gecacht: Ergebnisse ohne Hinweis bekommen `CacheConfig.default_ttl_ms`, dessen Standardwert `0` ist (sofort abgelaufen), sodass ein Server, der nichts deklariert, Aufruf für Aufruf genau denselben Verkehr sieht wie schon immer. -```python title="client.py" hl_lines="33 35 38" +Um das zu beobachten, stelle die `server.py` aus dem vorigen Abschnitt mit uvicorn bereit (ihre letzte Zeile baut die ASGI-App). Der Handler gibt jedes Mal eine Zeile aus, wenn er tatsächlich läuft: + +```console +uvicorn server:app --port 8000 +``` + +```python title="client.py" hl_lines="20 23 28" --8<-- "docs_src/caching/tutorial003.py" ``` +Führe `python client.py` in einem zweiten Terminal aus. Es gibt die Hinweise aus, die das erste Ergebnis trug: das `ttlMs` des Handlers neben dem `cacheScope` der Map: + +```text +1000 public +``` + +Das Terminal des Servers erzählt den Rest: Zwischen den Request-Logs von uvicorn erscheint `tools/list served` dreimal. + Vier Aufrufe, drei Abrufe. Der zweite Aufruf fand einen frischen Eintrag und erreichte den Server nie; die (injizierte) Uhr über die TTL hinaus vorzustellen ließ den dritten wieder abrufen; der vierte gab `cache_mode="refresh"` an. Dieses Keyword-Argument gibt es auf den fünf cachenden Verben (`list_tools`, `list_prompts`, `list_resources`, `list_resource_templates`, `read_resource`): * `"use"` (der Standardwert) liefert einen frischen Eintrag, wenn es einen gibt, und speichert andernfalls den Abruf. @@ -57,7 +71,7 @@ Vier Aufrufe, drei Abrufe. Der zweite Aufruf fand einen frischen Eintrag und err Eine Regel steht über `"use"`: **Aufrufe mit `meta` erreichen immer den Server.** Ein Request mit gesetztem `meta` (ein Progress-Token, Tracing-Felder) erwartet einen Request auf der Leitung, deshalb wird er unter `cache_mode="use"` wie `"refresh"` behandelt: Das Lesen aus dem Cache entfällt, und das abgerufene Ergebnis ersetzt trotzdem den gecachten Eintrag. `"bypass"` und ein explizites `"refresh"` verhalten sich wie immer. -Um das Caching ganz abzuschalten, konstruiere mit `Client(server, cache=None)`: Jeder Aufruf ist wieder ein Roundtrip, und `cache_mode` wird zwar weiter akzeptiert, bewirkt aber nichts. +Um das Caching ganz abzuschalten, übergib `cache=None` bei der Konstruktion des `Client`: Jeder Aufruf ist wieder ein Roundtrip, und `cache_mode` wird zwar weiter akzeptiert, bewirkt aber nichts. Auch der Scope wird automatisch beachtet: `"private"`-Einträge sind an die *Partition* des Caches gebunden (siehe unten), während `"public"`-Einträge sich für breiteres Teilen entscheiden können. Und **Benachrichtigungen schlagen die TTL** für genau die Einträge, die sie benennen: Eine `list_changed`-Benachrichtigung verdrängt die passende gecachte Liste, und `resources/updated` verdrängt den gecachten Lesevorgang, der unter exakt ihrem URI gespeichert ist – egal, wie frisch sie waren. Auf einer 2026-07-28-Verbindung kommen diese Benachrichtigungen auf einem `subscriptions/listen`-Stream an, den du mit `client.listen(...)` öffnest, und die Verdrängung ist abgeschlossen, bevor dein Watcher das Ereignis sieht; alles dazu steht in **[Abonnements](subscriptions.md)**. diff --git a/i18n/de/pages/client/callbacks.md b/i18n/de/pages/client/callbacks.md index ba7103a3..57d3216d 100644 --- a/i18n/de/pages/client/callbacks.md +++ b/i18n/de/pages/client/callbacks.md @@ -1,6 +1,6 @@ --- translation: - sections: [adf3c545b5be46b6, 916cd3ab1c03f461, e9be7a8d0eb0a456, 565890a636288ecf, 6af7e49db9129ec3, 06b0238c174186af, 90c6043be435fcb0] + sections: [adf3c545b5be46b6, 916cd3ab1c03f461, 32ef568335dd95a7, 565890a636288ecf, 6af7e49db9129ec3, 06b0238c174186af, 0abc5ea5cb7ff6b3] tool: 1 --- # Client-Callbacks {#client-callbacks} @@ -63,7 +63,7 @@ Ein `tools/call` von dir, ein `elicitation/create` zurück vom Server, beantwort `mode="legacy"` im `Client(...)`-Aufruf leistet echte Arbeit. Standardmäßig handelt `Client(...)` den modernen Protokollpfad aus, und dieser Pfad hat keinen Rückkanal (back-channel) für Requests vom Server an den Client: `ctx.elicit` schlägt fehl, bevor dein Callback überhaupt läuft. Das entscheidet nicht der Transport, sondern das ausgehandelte - Protokoll – in-memory genauso wie über eine URL. Setze `mode="legacy"` fest, wann immer dein Client + Protokoll. Setze `mode="legacy"` fest, wann immer dein Client einen solchen Request beantworten muss; jeder Test hinter dieser Seite tut das. Alles Weitere steht in **[Protokollversionen](../protocol-versions.md)**. In einer 2026-07-28-Session ist der Callback nicht tot, er wird nur anders gespeist: Gibt ein Tool ein @@ -151,4 +151,4 @@ Zwei weitere. Keiner deklariert etwas. * `sampling_callback` und `list_roots_callback` funktionieren genauso, bedienen aber veraltete Features; moderne Server verwenden stattdessen Multi-Roundtrip-Requests. * `logging_callback` und `message_handler` empfangen Benachrichtigungen. Sie deklarieren nichts. -Das erste Argument von `Client(...)` ist ein Transport-Objekt. **[Client-Transporte](transports.md)** behandelt jede Art davon. +Das erste Argument von `Client(...)` wählt den Transport. **[Client-Transporte](transports.md)** behandelt jede Art davon. diff --git a/i18n/de/pages/client/index.md b/i18n/de/pages/client/index.md index 4a5a9361..a3ce8240 100644 --- a/i18n/de/pages/client/index.md +++ b/i18n/de/pages/client/index.md @@ -1,6 +1,6 @@ --- translation: - sections: [ebef1e7a0df854f4, 8355cfaf1f76c9d5, 8e79141fc2985342, 46bdb07c7537e8a5, 80ce41579825a6fa, 5f0fa90494de8f65, 83d10514eaa62fa5, 9190555aa39a5d28, 84a4c9d8bf14dddb, 927d71cf40b58c30] + sections: [ebef1e7a0df854f4, 7e16449f66e7dfd6, eeb0682f7d2a1079, 5713f0196a34e6e7, 0e844597859e4248, 3a97d9195ddcc92e, 1da08c483e59c141, 84702cc6e0a1fd42, 8dee7a31c86ffc5c, 83a5bce168ef23d7] tool: 1 --- # Der Client {#the-client} @@ -11,13 +11,23 @@ Er ist ein einziges Objekt mit einem einzigen Lebenszyklus: erzeugen, `async wit ## Der erste Client {#your-first-client} -```python title="client.py" hl_lines="14-18" +Ein Client braucht einen Server, mit dem er sprechen kann. Mit diesem Bookshop verbindet sich jedes Beispiel auf dieser Seite. Speichere ihn als `server.py` und lass ihn über HTTP laufen: + +```python title="server.py" --8<-- "docs_src/client/tutorial001.py" ``` -Der Server oben ist nur da, damit du etwas hast, womit du dich verbinden kannst. Der Client sind die fünf hervorgehobenen Zeilen. +```console +uv run mcp run server.py --transport streamable-http +``` -* `Client(mcp)` bekommt das **Server-Objekt selbst**. Das ist der In-Memory-Transport: kein Subprozess, kein Port, kein HTTP. So verbindet sich jedes Beispiel auf dieser Seite und jeder Test, den du schreibst. +Damit läuft er unter `http://localhost:8000/mcp`. Der Client ist ein eigenes Programm. Speichere ihn als `client.py` und führe in einem zweiten Terminal `python client.py` aus: + +```python title="client.py" hl_lines="7-11" +--8<-- "docs_src/client/tutorial001_client.py" +``` + +* `Client("http://localhost:8000/mcp")` bekommt eine **URL** und verbindet sich deshalb über Streamable HTTP mit dem Server, den du gerade gestartet hast. * `async with` ist der **Lebenszyklus**. Beim Betreten wird verbunden und ausgehandelt, beim Verlassen getrennt. Es gibt kein `connect()`/`close()`-Paar, und ein `Client` lässt sich nach dem Ende des Blocks nicht wiederverwenden. * Innerhalb des Blocks liegen die Fakten zur Verbindung bereits als einfache Properties vor. @@ -25,10 +35,10 @@ Der Server oben ist nur da, damit du etwas hast, womit du dich verbinden kannst. `Client` nimmt ein positionelles Argument und leitet den Transport aus dessen Typ ab: -* Eine Instanz von `MCPServer` (oder des Low-Level-`Server`): Verbindung **im selben Prozess**. -* Ein URL-String (`Client("http://localhost:8000/mcp")`): Streamable HTTP, der Weg für die Produktion. -* Ein `StdioServerParameters`: der Befehl, der als **Subprozess** gestartet wird und mit dem über dessen stdin und stdout gesprochen wird. +* Ein URL-String (`Client("http://localhost:8000/mcp")`): Streamable HTTP, der Transport, hinter dem du deinen Server bereitstellst. +* Ein `StdioServerParameters`: der Befehl, der als lokaler **Subprozess** gestartet wird und mit dem über dessen stdin und stdout gesprochen wird. * Ein **Transport**: alles, was sich mit `async with ... as (read, write)` verwenden lässt, etwa `streamable_http_client(url, http_client=...)` um deinen eigenen HTTP-Client herum. +* Eine Instanz von `MCPServer` (oder des Low-Level-`Server`): Verbindung **im selben Prozess**, ohne Subprozess und ohne Port. Das ist die Variante für Tests, und **[Testen](../get-started/testing.md)** baut darauf auf. Alles Übrige auf dieser Seite ist in allen vier Fällen identisch. Header, Subprozesse, Timeouts und das `Transport`-Protokoll haben ihre eigene Seite: **[Client-Transporte](transports.md)**. @@ -49,11 +59,11 @@ Eine Protokollversion hast du nie ausgewählt. Standardmäßig sondiert der `Cli ## Tools auflisten {#listing-tools} -```python title="client.py" hl_lines="15-20" +```python title="client.py" hl_lines="8-13" --8<-- "docs_src/client/tutorial002.py" ``` -`list_tools()` gibt ein `ListToolsResult` zurück; die Tools stehen in `.tools`. Jedes davon ist die vollständige Definition, die ein Host einem Modell übergeben würde: +`list_tools()` gibt ein `ListToolsResult` zurück; die Tools stehen in `.tools`. Jedes davon ist die vollständige Definition, die ein Host einem Modell übergeben würde. Hier ist das erste: ```python tool.name # 'search_books' @@ -77,6 +87,8 @@ und `tool.input_schema` ist das JSON-Schema, das der Server aus den Type Hints d Dieses Schema ist alles, was eine UI braucht, um ein Argumentformular zu rendern, und alles, was ein Modell braucht, um gültige Argumente zu erzeugen. +Das zweite Tool, `lookup_book`, wurde ohne `title=` registriert, sein `tool.title` ist also `None`. + !!! tip `title` ist optional, also muss sich eine UI, die einem Menschen Tools anzeigt, entscheiden: den `title`, wenn es einen gibt, sonst den `name`. `from mcp.shared.metadata_utils import get_display_name` macht genau das – @@ -86,7 +98,7 @@ Dieses Schema ist alles, was eine UI braucht, um ein Argumentformular zu rendern `call_tool(name, arguments)` führt das Tool aus und gibt dir ein `CallToolResult` zurück. -```python title="client.py" hl_lines="27-34" +```python title="client.py" hl_lines="9-16" --8<-- "docs_src/client/tutorial003.py" ``` @@ -142,7 +154,7 @@ Ein Tool, das eine Exception auslöst, löst in deinem Client **keine** aus. Es Die Ressourcen-Verben kommen paarweise: zwei Wege zum Auflisten, einer zum Lesen. -```python title="client.py" hl_lines="22-31" +```python title="client.py" hl_lines="9-18" --8<-- "docs_src/client/tutorial004.py" ``` @@ -156,7 +168,7 @@ Ein Client kann sich auch mitteilen lassen, wann sich eine Ressource ändert. Au ## Prompts {#prompts} -```python title="client.py" hl_lines="15-20" +```python title="client.py" hl_lines="8-13" --8<-- "docs_src/client/tutorial005.py" ``` @@ -181,7 +193,7 @@ Ein Host reicht diese Nachrichten direkt an das Modell weiter. Das ist das ganze Ein Server mit einem Handler für Vervollständigungen kann Argumente von Prompts und Ressourcen-Templates automatisch vervollständigen, während die Person tippt. -```python title="client.py" hl_lines="27-31" +```python title="client.py" hl_lines="9-13" --8<-- "docs_src/client/tutorial006.py" ``` @@ -194,21 +206,21 @@ Die Antwort steht in `result.completion.values`. Tippe `"p"`, und der Server lie Jede `list_*`-Methode nimmt ein Keyword-Argument `cursor=`, und jedes Ergebnis trägt einen `next_cursor`. Wenn `next_cursor` `None` ist, hast du alles. -```python title="client.py" hl_lines="22-30" +```python title="client.py" hl_lines="7-15" --8<-- "docs_src/client/tutorial007.py" ``` -Diese Schleife ist gegenüber jedem Server korrekt. `MCPServer` gibt alles auf einer Seite zurück, also ist `next_cursor` `None` und die Schleife läuft einmal – deshalb schreibt der meiste Code sie nie. Server, die wirklich paginieren, und die Regeln, denen Cursor gehorchen, stehen in **[Paginierung](../advanced/pagination.md)**. +`list_all_tools` ist gegenüber jedem Server korrekt. `MCPServer` gibt alles auf einer Seite zurück, also ist `next_cursor` `None` und die Schleife läuft einmal – deshalb schreibt der meiste Code sie nie. Server, die wirklich paginieren, und die Regeln, denen Cursor gehorchen, stehen in **[Paginierung](../advanced/pagination.md)**. ## In Tests {#in-tests} -`Client(mcp)` ohne Prozess und ohne Port ist bereits ein Test-Harness für deinen Server. +Jede `client.py` auf dieser Seite hat `server.py` über HTTP erreicht. In einem Test überspringst du das Netzwerk und übergibst `Client` das Server-Objekt selbst: `from server import mcp`, dann `Client(mcp)`. Kein Prozess, kein Port, und jede Methode von oben funktioniert genauso. -Dafür gibt es ein eigenes Konstruktor-Flag: `Client(mcp, raise_exceptions=True)`. Es wirkt nur auf In-Memory-Verbindungen, und **[Testen](../get-started/testing.md)** ist die Seite, die es erklärt und das ganze Muster darum herum aufbaut. +Dafür gibt es ein eigenes Konstruktor-Flag: `Client(mcp, raise_exceptions=True)`. Es wirkt nur auf Verbindungen im selben Prozess, und **[Testen](../get-started/testing.md)** ist die Seite, die es erklärt und das ganze Muster darum herum aufbaut. ## Zusammenfassung {#recap} -* `Client(x)` verbindet sich in-memory mit einem Server-Objekt, über Streamable HTTP mit einem URL-String und über alles andere per Transport. +* `Client(x)` verbindet sich über Streamable HTTP mit einem URL-String, startet für ein `StdioServerParameters` einen Subprozess, betritt einen Transport direkt und nimmt in Tests das Server-Objekt selbst. * `async with` ist der ganze Lebenszyklus. Darin sind `server_capabilities` und `protocol_version` bereits gefüllt; `server_info` und `instructions` ebenfalls, wenn der Server sie liefert. * `list_tools()` gibt dir für jedes Tool `name`, `title`, `description` und `input_schema`. * `call_tool()` gibt `content` für das Modell, `structured_content` für deinen Code und `is_error` zurück. Ein Tool, das eine Exception auslöst, ist ein Ergebnis, keine Exception. diff --git a/i18n/de/pages/client/oauth-clients.md b/i18n/de/pages/client/oauth-clients.md index bc2a86dc..d0b32ce8 100644 --- a/i18n/de/pages/client/oauth-clients.md +++ b/i18n/de/pages/client/oauth-clients.md @@ -1,6 +1,6 @@ --- translation: - sections: [c6899d3892bd9fa0, 79372cff3cc48a88, 63878d29e87c3e73, 13175843d3588af4, e7e2b9fd516f77de, 758f06399b513c1f, a05d7278487d610b] + sections: [c6899d3892bd9fa0, 79372cff3cc48a88, c2dae1ebe2ebd543, 13175843d3588af4, df06056fb16b3846, 758f06399b513c1f, a05d7278487d610b] tool: 1 --- # OAuth-Clients {#oauth-clients} @@ -81,18 +81,20 @@ Sieh dir `main()` an. Der Provider kommt an den **httpx2-Client**, der httpx2-Cl Wenn `Client` zum ersten Mal einen Request schickt, antwortet der Server mit `401`. Der Provider übernimmt: -1. **Discovery.** Er liest den `WWW-Authenticate`-Header, holt die Protected Resource Metadata des Servers von `/.well-known/oauth-protected-resource`, erfährt, welcher Autorisierungsserver diese Ressource schützt, und holt die Metadaten *dieses* Servers. +1. **Discovery.** Er liest den `WWW-Authenticate`-Header, holt die Protected Resource Metadata des Servers von `/.well-known/oauth-protected-resource`, erfährt, welcher Autorisierungsserver diese Ressource schützt, und holt die Metadaten *dieses* Servers. (Ein älterer Server, der keine Ressourcen-Metadaten veröffentlicht, wird stattdessen an seinem eigenen Origin nach Autorisierungsserver-Metadaten gefragt.) So oder so müssen die Metadaten als `issuer` den Server nennen, für den sie geholt wurden; alles andere wird abgelehnt. 2. **Registrierung.** Nichts im Speicher? Er registriert dich dynamisch mit deiner `OAuthClientMetadata` und speichert das Ergebnis. 3. **Autorisierung.** Er generiert das PKCE-Paar und einen `state`, baut die Autorisierungs-URL, awaited deinen `redirect_handler` und awaited dann deinen `callback_handler` für den Code. 4. **Austausch.** Er tauscht den Code gegen ein `OAuthToken`, speichert es und wiederholt deinen ursprünglichen Request mit `Authorization: Bearer ...`. Danach ist Ruhe. Tokens kommen aus dem Speicher, ein abgelaufenes Access-Token wird mit dem Refresh-Token erneuert, und erst wenn nichts davon klappt, führt er den Flow erneut aus. +Eine Transportregel gilt für alle diese Requests: Wie der MCP-Request, in dem sie laufen, folgen sie einem Redirect nur, wenn er auf demselben Origin bleibt und die Methode beibehält (etwa ein 307/308 wegen eines Schrägstrichs am Ende), und behandeln jeden anderen Redirect so, als würde diese URL nicht antworten. + Nichts davon hast du geschrieben. Zwei Keyword-Argumente bleiben übrig (`client_metadata_url` und `validate_resource_url`), und diese Datei braucht keines davon. `client_metadata_url` ist dasjenige, das man kennen sollte; es bekommt unten einen eigenen Abschnitt. ### Ausprobieren {#try-it} -Die meisten Beispiele in dieser Dokumentation kannst du mit einem In-Memory-`Client(server)` prüfen. Dieses nicht: Der ganze Sinn des Flows ist ein HTTP-`401`, und zwischen einem In-Memory-Client und seinem Server gibt es kein HTTP. +Der In-Memory-`Client(server)`, den deine Tests verwenden, hilft hier nicht: Der ganze Sinn des Flows ist ein HTTP-`401`, und zwischen einem In-Memory-Client und seinem Server gibt es kein HTTP. Das Repository liefert die Live-Variante mit. `examples/servers/simple-auth/` betreibt einen eigenständigen Autorisierungsserver und einen geschützten MCP-Server; `examples/clients/simple-auth-client/` ist der Client dieser Seite, ausgebaut zu einem kleinen CLI. Sein README enthält die beiden Befehle: Starte die Server, lass den Client gegen sie laufen, und du siehst die vier Schritte vorbeiziehen. @@ -110,13 +112,14 @@ Ein nächtlicher Job, ein CI-Schritt, ein anderer Dienst. Es gibt keinen Browser `ClientCredentialsOAuthProvider` ist dasselbe `httpx2.Auth`, ohne den Menschen: -```python title="client.py" hl_lines="4 27-33" +```python title="client.py" hl_lines="4 27-34" --8<-- "docs_src/oauth_clients/tutorial002.py" ``` Was sich geändert hat: * Keine `OAuthClientMetadata`, keine Handler. Du übergibst `client_id` und `client_secret`; der Provider baut eine minimale `client_credentials`-Registrierung darum herum und überspringt die dynamische Registrierung komplett. +* `issuer` benennt den Autorisierungsserver, der diese Zugangsdaten ausgestellt hat; verwende den `issuer`-Wert, den sein Dokument unter `/.well-known/oauth-authorization-server` zurückgibt. Die Discovery läuft weiterhin wie oben, aber Token-Requests werden ausschließlich aus Metadaten für *diesen* Issuer gebaut; zeigt der MCP-Server irgendwo anders hin, bricht der Flow stattdessen mit einem `OAuthFlowError` ab. Ihn wegzulassen ist veraltet, und ab 3.0 ist er erforderlich (siehe **[Veraltete Features](../deprecated.md#deprecated-sdk-helpers)**); bis dahin warnt der Provider und verwendet den Autorisierungsserver, den die Discovery findet. * `scope` ist ein durch Leerzeichen getrennter String, das OAuth-Format auf der Leitung. * Alles danach ist identisch: dasselbe `TokenStorage`, derselbe `httpx2.AsyncClient(auth=...)`, derselbe `streamable_http_client`. @@ -129,7 +132,7 @@ Standardmäßig reist das Secret als HTTP Basic Auth im Token-Request (`client_s Ein weiterer Provider liegt in `mcp.client.auth.extensions.client_credentials`: **`PrivateKeyJWTOAuthProvider`**, für Clients, die sich mit einem JWT statt einem gemeinsamen Secret authentifizieren (`private_key_jwt`, die Variante mit Schlüsselpaar und Workload-Identität). Er folgt - demselben Muster: einen erzeugen, auf `auth=` setzen. Dasselbe Modul liefert + demselben Muster: einen erzeugen (er nimmt denselben optionalen `issuer` entgegen), auf `auth=` setzen. Dasselbe Modul liefert `SignedJWTParameters` und `static_assertion_provider`, zwei Helfer, die seine Assertion bauen. Es gibt noch eine Situation ohne Menschen: Der Client gehört zu einem Unternehmen, dessen Identity Provider – nicht die Person am Host – entscheidet, welche MCP-Server er erreichen darf. Das ist ein anderer Grant mit eigenem Vertrauensmodell und eigener Seite: **[Identity Assertion](identity-assertion.md)**. diff --git a/i18n/de/pages/client/session-groups.md b/i18n/de/pages/client/session-groups.md index e94089b2..0f34e596 100644 --- a/i18n/de/pages/client/session-groups.md +++ b/i18n/de/pages/client/session-groups.md @@ -1,6 +1,6 @@ --- translation: - sections: [09c857a25a9dc37a, 43bc6a76a243a50e, 0a716022a88768df, 4b7f78042bfcfff7, c112662e61b03315, 58974ba1f489a8b4, d18adbdbb835ea73] + sections: [09c857a25a9dc37a, 43bc6a76a243a50e, 0a716022a88768df, 4b7f78042bfcfff7, c112662e61b03315, 58974ba1f489a8b4, ed4d17e894864056] tool: 1 --- # Session-Gruppen {#session-groups} @@ -78,7 +78,7 @@ Hältst du bereits eine verbundene `ClientSession` (`Client.session` ist eine), ## Zusammenfassung {#recap} * `ClientSessionGroup` hält viele Server-Verbindungen und führt deren Tools, Ressourcen und Prompts in je ein `dict` zusammen. -* `connect_to_server(params)` pro Server. Es nimmt Transport-Parameter entgegen, nie das Server-Objekt oder die URL, die ein `Client` entgegennimmt. +* `connect_to_server(params)` pro Server. Es nimmt Transport-Parameter entgegen, nie die URL oder den `Transport`, die ein `Client` entgegennimmt. * `group.call_tool(name, arguments)` leitet den Aufruf für dich an den zuständigen Server weiter. * Namen müssen in der gesamten Gruppe eindeutig sein; zwei Server mit einem `search`-Tool können nicht ohne Weiteres nebeneinander bestehen. * `component_name_hook=` schreibt jeden registrierten Namen um. Der Dict-Schlüssel ändert sich, der Name auf der Leitung nicht. diff --git a/i18n/de/pages/client/transports.md b/i18n/de/pages/client/transports.md index b605938f..75de6f81 100644 --- a/i18n/de/pages/client/transports.md +++ b/i18n/de/pages/client/transports.md @@ -1,6 +1,6 @@ --- translation: - sections: [9cac816674181eb0, 0700f337babcd4dd, 2bde0dd58cdf00f5, 40b4916d82eaf1d4, 3d0832f39b0d7059, dfa4446556badef0, 5bd93be2ab2ecb9c] + sections: [9cac816674181eb0, 7c157764133fea1f, 40b4916d82eaf1d4, 10d151f2cc75317f, 3d0832f39b0d7059, 92742ba36533633d, 0aeca6145e7bd302] tool: 1 --- # Client-Transporte {#client-transports} @@ -11,30 +11,15 @@ Du konfigurierst nie einen separat. `Client` nimmt ein einziges positionales Arg Die *Server*-Seite jedes Transports (was `mcp.run()` tut und was du bereitstellst) steht in **[Den Server betreiben](../run/index.md)**. -## Im Speicher {#in-memory} - -Übergib das Server-Objekt selbst: - -```python title="client.py" hl_lines="14" ---8<-- "docs_src/client_transports/tutorial001.py" -``` - -Kein Subprozess, kein Port, keine Bytes auf einer Leitung. Client und Server sind zwei Objekte im selben Prozess, und der Aufruf läuft trotzdem durch die echte Protokollschicht: `search_books` wird genau so aufgelistet, validiert und aufgerufen, wie es über HTTP geschähe. - -Damit ist es zwei Dinge zugleich: - -* **Eine Testumgebung.** Jedes Beispiel in dieser Dokumentation wird so ausgeführt, und die Seite **[Testen](../get-started/testing.md)** baut das ganze Muster darauf auf. -* **Eine Embedding-API.** Eine Anwendung, die den Server selbst erzeugt, braucht keinen Netzwerk-Hop, um dessen Tools aufzurufen. - ## Streamable HTTP {#streamable-http} -Übergib einen URL-String und du bekommst **Streamable HTTP**, den Transport, hinter dem du bereitstellst: +Übergib einen URL-String und du bekommst **Streamable HTTP** – den Transport, hinter dem du bereitstellst und zu dem du zuerst greifen solltest: ```python title="client.py" hl_lines="5" --8<-- "docs_src/client_transports/tutorial002.py" ``` -Das ist der ganze Produktions-Client. `Client` packt die URL für dich in `streamable_http_client(...)`, auf Basis eines `httpx2.AsyncClient`, der so konfiguriert ist, wie MCP es braucht: `follow_redirects=True`, ein Timeout von 30 Sekunden für connect/write/pool und ein Read-Timeout von 300 Sekunden, weil der Server einen Response-Stream offen halten kann. +Das ist der ganze Produktions-Client. `Client` packt die URL für dich in `streamable_http_client(...)`, auf Basis eines `httpx2.AsyncClient`, der so konfiguriert ist, wie MCP es braucht: ein Timeout von 30 Sekunden für connect/write/pool und ein Read-Timeout von 300 Sekunden, weil der Server einen Response-Stream offen halten kann. !!! check Ein `Client`, den du erzeugt hast, ist **nicht** verbunden. Das Erzeugen wählt nur den Transport; @@ -50,7 +35,7 @@ Das ist der ganze Produktions-Client. `Client` packt die URL für dich in `strea Sobald du einen `Authorization`-Header, ein Cookie, einen Proxy, mTLS oder ein anderes Timeout brauchst, baust du den `httpx2.AsyncClient` selbst und übergibst ihn an `streamable_http_client`: -```python title="client.py" hl_lines="8-14" +```python title="client.py" hl_lines="8-13" --8<-- "docs_src/client_transports/tutorial003.py" ``` @@ -80,9 +65,30 @@ oder übergibst deinem `httpx2.AsyncClient` ein explizites `verify=ssl_context` !!! info `httpx2` behält die vertraute `httpx`-API bei. Wenn du `httpx` kennst, weißt du hier also bereits, wie Auth, Proxys, Event-Hooks, Retries und Verbindungslimits gehen. Das SDK fügt nichts hinzu und nimmt - nichts weg. Hier dockt auch OAuth an: + nichts weg – außer bei der [Behandlung von Redirects](#redirects). Hier dockt auch OAuth an: `httpx2.AsyncClient(auth=OAuthClientProvider(...))`. Der ganze Ablauf steht in **[OAuth-Clients](oauth-clients.md)**. +### Redirects {#redirects} + +Der Transport verbindet sich mit der URL, die du ihm gegeben hast, und nur mit diesem Origin. + +* Einem `307`/`308`-Redirect, der auf demselben Schema, Host und Port bleibt, wird gefolgt, ebenso `http://` → `https://` auf demselben Host. Das deckt den üblichen Trailing-Slash-Redirect `/mcp` → `/mcp/` ab. +* Einem Redirect irgendwo anders hin wird **nicht** gefolgt. Der Aufruf schlägt fehl mit: + + ```text + MCPError: Redirect to https://other.example.com/mcp not followed; use that URL as the endpoint if it is the intended server + ``` + + Ist diese URL der Server, den du meintest, trag sie in deine Konfiguration ein. Wenn nicht, ist der Server oder ein Proxy davor falsch konfiguriert. + +Das gilt für jeden `httpx2.AsyncClient`, den du übergibst: Seine Einstellung `follow_redirects` wird für MCP-Requests nicht herangezogen, in keine der beiden Richtungen. Die OAuth-Provider des SDK wenden dieselbe Regel auf ihre eigenen Requests an. + +!!! tip + `Redirect to http://… not followed: it would downgrade this HTTPS endpoint to plain HTTP` bedeutet, dass der + Server hinter einem TLS-terminierenden Proxy sitzt, von dem er nichts weiß, und `http://`-Redirects ausgibt. + Das behebst du auf dem Server (**[Bereitstellen und skalieren](../run/deploy.md#behind-a-tls-terminating-proxy)**) + oder indem du genau die `https://…/`-URL verwendest, die die Meldung vorschlägt. + ## stdio {#stdio} Ein **stdio**-Server ist ein Subprozess. Der Client startet ihn, schreibt JSON-RPC in seine stdin und liest JSON-RPC aus seiner stdout. So betreibt ein Desktop-Host einen Server auf deinem Rechner: Ein Host *ist* dieser Code plus eine UI, und **[Mit einem echten Host verbinden](../get-started/real-host.md)** zeigt dieselbe Beziehung von der Seite des Hosts, als Konfigurationsdatei. @@ -105,6 +111,18 @@ Die stderr des Kindprozesses landet in deiner. Um sie woandershin zu leiten, bau Ein Server, der einen API-Key braucht, findet ihn dort nicht. Übergib ihn explizit mit `env=`; diese Variablen werden über die Allow-List gelegt. Genau das tut `BOOKSHOP_API_KEY` oben. +## Im Speicher {#in-memory} + +In einem Test gibt es nichts bereitzustellen und nichts zu starten. Übergib das Server-Objekt selbst: + +```python hl_lines="14" +--8<-- "docs_src/client_transports/tutorial001.py" +``` + +Kein Subprozess, kein Port, keine Bytes auf einer Leitung. Client und Server sind zwei Objekte im selben Prozess, und der Aufruf läuft trotzdem durch die echte Protokollschicht: `search_books` wird genau so aufgelistet, validiert und aufgerufen, wie es über HTTP geschähe. **[Testen](../get-started/testing.md)** baut das ganze Muster darauf auf. + +Dieselbe Form dient zugleich als Embedding-API: Eine Anwendung, die den Server selbst erzeugt, kann dessen Tools ohne Netzwerk-Hop aufrufen. + ## SSE {#sse} `sse_client(url)` aus `mcp.client.sse` ist der HTTP-Transport, den Streamable HTTP abgelöst hat. Pack ihn genauso ein, `Client(sse_client("http://localhost:8000/sse"))`, um mit einem Server zu sprechen, der ihn noch verwendet – und bau nichts Neues darauf. @@ -113,15 +131,16 @@ Die stderr des Kindprozesses landet in deiner. Um sie woandershin zu leiten, bau Für `Client` ist alles oben Genannte dasselbe. -Ein **Transport** ist ein beliebiger asynchroner Kontextmanager, der ein `(read, write)`-Paar von Nachrichten-Streams liefert: formal das `Transport`-Protokoll in `mcp.client`. `Client` löst sein Argument nach Typ auf: Ein Server-Objekt verbindet im Prozess, ein `str` wird zu `streamable_http_client(url)`, ein `StdioServerParameters` wird zu `stdio_client(params)`, und alles andere wird direkt als Transport betreten. Diese letzte Regel ist der Grund, warum `stdio_client(...)`, `streamable_http_client(...)` und `sse_client(...)` alle in denselben Platz passen – und warum du deinen eigenen schreiben kannst. +Ein **Transport** ist ein beliebiger asynchroner Kontextmanager, der ein `(read, write)`-Paar von Nachrichten-Streams liefert: formal das `Transport`-Protokoll in `mcp.client`. `Client` löst sein Argument nach Typ auf: Ein `str` wird zu `streamable_http_client(url)`, ein `StdioServerParameters` wird zu `stdio_client(params)`, ein Server-Objekt verbindet im Prozess, und alles andere wird direkt als Transport betreten. Diese letzte Regel ist der Grund, warum `stdio_client(...)`, `streamable_http_client(...)` und `sse_client(...)` alle in denselben Platz passen – und warum du deinen eigenen schreiben kannst. ## Zusammenfassung {#recap} -* `Client(mcp)` (das Server-Objekt) verbindet im Speicher. Nutze es für Tests und zum Einbetten. * `Client("http://.../mcp")` (eine URL) verbindet über Streamable HTTP, den Produktions-Transport. * Header, Auth, Proxys und Timeouts gehören auf einen `httpx2.AsyncClient`, den du an `streamable_http_client(url, http_client=...)` übergibst. Es gibt kein Keyword `headers=`. +* Redirects wird nur innerhalb des eigenen Origins der URL gefolgt (ein Trailing-Slash-`307`/`308`), plus `http`→`https` auf demselben Host. Alles andere schlägt mit `Redirect to … not followed` fehl; konfiguriere die endgültige URL. * stdio ist `Client(StdioServerParameters(...))`. Pack es nur dann selbst in `stdio_client(...)` ein, wenn du die stderr des Kindprozesses umleiten willst. * Der Subprozess bekommt eine Umgebung per Allow-List, nicht deine; `env=` ergänzt sie. +* `Client(mcp)` (das Server-Objekt) verbindet im Speicher. Nutze es in Tests oder um einen Server in die Anwendung einzubetten, die ihn gebaut hat. * Ein Transport ist alles, womit du `async with x as (read, write)` schreiben kannst. Alles, was weder Server-Objekt noch URL noch `StdioServerParameters` ist, reicht `Client` direkt an dieses Protokoll weiter. * Das Erzeugen eines `Client` wählt den Transport. `async with` öffnet ihn. diff --git a/i18n/de/pages/deprecated.md b/i18n/de/pages/deprecated.md index 0887f2a8..0655c7ec 100644 --- a/i18n/de/pages/deprecated.md +++ b/i18n/de/pages/deprecated.md @@ -1,11 +1,11 @@ --- translation: - sections: [490237e61c3a7a44, 01262a123ad9501d, 429db5b574a2ac08, e2d0d273fbd2d74b, 64ab0331e868f3d4, 6c8878ce2d1f6d56, 4068f23e371bf0b3, eaef75b8725bc931] + sections: [4c1dea378b2b1bf7, 01262a123ad9501d, 429db5b574a2ac08, e2d0d273fbd2d74b, 64ab0331e868f3d4, 6c8878ce2d1f6d56, c3d1099701156881, e185a1b8e53669f6] tool: 1 --- # Veraltete Features {#deprecated-features} -Die Spec 2026-07-28 mustert fünf Dinge aus. Das SDK implementiert jedes davon weiterhin, und jedes davon trägt jetzt eine **Deprecation-Warnung**. Ein SDK-Helfer ist unabhängig davon veraltet und steht [am Ende](#deprecated-sdk-helpers). +Die Spec 2026-07-28 mustert fünf Dinge aus. Das SDK implementiert jedes davon weiterhin, und jedes davon trägt jetzt eine **Deprecation-Warnung**. Ein paar Dinge sind unabhängig davon nur auf SDK-Ebene veraltet; sie stehen [am Ende](#deprecated-sdk-helpers). Die Tabelle unten nennt jedes veraltete Feature, den Grund, warum es verschwindet, und den Ersatz, auf dem du aufbauen solltest. @@ -138,11 +138,13 @@ Das ist die ganze API. Es gibt keinen Schalter pro Methode, und du willst auch k ## Veraltete SDK-Helfer {#deprecated-sdk-helpers} -Das sind keine Spec-Änderungen, sondern nur SDK-Interna mit einem besseren Ersatz. Sie warnen mit derselben `MCPDeprecationWarning` und werden in 3.0 entfernt. +Das sind keine Spec-Änderungen, sondern nur Verwendungsweisen des SDK mit einem besseren Ersatz. Sie warnen mit derselben `MCPDeprecationWarning`, und 3.0 entfernt die alte Form. | Veraltet | Was du stattdessen tust | |---|---| | `FuncMetadata.call_fn_with_arg_validation()` | `FuncMetadata.validate_arguments()` und danach `FuncMetadata.call_fn()`. Aufgerufen hat es ohnehin nur Code, der `FuncMetadata` direkt ansteuert (etwa eine eigene `Tool`-Unterklasse). | +| `AuthSettings(resource_server_url=...)` ohne `validate_token_resource=` | Setze es: Mit `True` lehnt der Server Bearer-Tokens ab, die dein Verifier nicht als für `resource_server_url` ausgestellt meldet; `False` besagt, dass dein Verifier die Audience des Tokens selbst prüft (siehe **[Autorisierung](run/authorization.md#a-token-verifier)**). Nicht gesetzt verhält es sich wie `False`; 3.0 macht `True` zum Standardwert, sobald `resource_server_url` gesetzt ist. | +| `ClientCredentialsOAuthProvider(...)` oder `PrivateKeyJWTOAuthProvider(...)` ohne `issuer=` | Übergib `issuer=` und benenne damit den Autorisierungsserver, der die Credentials ausgestellt hat (siehe **[OAuth-Clients schreiben](client/oauth-clients.md#machine-to-machine)**). Ohne ihn entscheidet der MCP-Server, welcher Autorisierungsserver sie erhält; 3.0 macht das Keyword-Argument verpflichtend. | ## Zusammenfassung {#recap} @@ -151,7 +153,7 @@ Das sind keine Spec-Änderungen, sondern nur SDK-Interna mit einem besseren Ersa * Veraltet ist ein Hinweis: keine Änderungen auf der Leitung, alles funktioniert weiterhin gegen Sessions von vor 2026, und du bekommst eine sichtbare `MCPDeprecationWarning` (eine `UserWarning`, also standardmäßig aktiv). * Sampling und Roots brauchen zusätzlich einen Rückkanal, den eine 2026-07-28-Session nicht hat. Auf einer modernen Verbindung warnen sie und werfen dann eine Exception. * `warnings.filterwarnings("ignore", category=MCPDeprecationWarning)` bringt die ganze Kategorie zum Schweigen; `"error::mcp.MCPDeprecationWarning"` in pytest macht daraus einen fehlschlagenden Test. -* Ein SDK-Helfer, `FuncMetadata.call_fn_with_arg_validation()`, ist separat veraltet und wird in 3.0 entfernt. +* Für die [veralteten SDK-Helfer](#deprecated-sdk-helpers) gilt dieselbe Regel: Sie warnen jetzt, und 3.0 entfernt die alte Form. * Neuer Code sollte auf nichts davon aufbauen. Jede andere Seite dieser Dokumentation vermittelt die aktuelle API. diff --git a/i18n/de/pages/get-started/first-steps.md b/i18n/de/pages/get-started/first-steps.md index e841e02b..d9e2ba0d 100644 --- a/i18n/de/pages/get-started/first-steps.md +++ b/i18n/de/pages/get-started/first-steps.md @@ -1,6 +1,6 @@ --- translation: - sections: [0d6c05bcbf836bf3, 59a7b14eeefc68c1, 7114d8d6daba203f, e8bbb56a98ba7bc9, 5138010f6159901c, f78da7c7c363d4c6, 220a939cab348686] + sections: [0d6c05bcbf836bf3, 9a78b5f6b44b18ab, 7114d8d6daba203f, e8bbb56a98ba7bc9, bfd2fd1153e71dac, 1615a994ef071fdd, 65c599fae991f245] tool: 1 --- # Erste Schritte {#first-steps} @@ -17,7 +17,7 @@ Drei Wörter, die dir ab hier auf jeder Seite begegnen: * Ein **Client** lebt im Host und spricht MCP. Der Host betreibt einen Client pro Server, mit dem er verbunden ist. * Ein **Server** ist das, was du mit diesem SDK baust. Er stellt Clients Dinge bereit. Mit dem Modell spricht er nie direkt. -Du schreibst den Server. Hosts sind das Produkt anderer. Das SDK gibt dir außerdem einen `Client`. Mit ihm testest du deine Server, und er taucht weiter unten auf dieser Seite auf. +Du schreibst den Server. Hosts sind das Produkt anderer. Das SDK gibt dir außerdem einen `Client` – dieselbe Klasse, mit der ein Host einen Server per URL erreichen oder als Subprozess starten würde. Er taucht weiter unten auf dieser Seite auf, und mit ihm testest du auch deine Server. ## Die drei Primitive {#the-three-primitives} @@ -83,22 +83,20 @@ Du hast im Inspector drei Tabs gesehen. Woher wusste er, dass es drei sind? Wenn sich ein Client verbindet, deklariert der Server seine **Capabilities**: welche Familien von Requests er beantwortet. Der Client entscheidet anhand dieser Deklaration, wonach er überhaupt fragt. Du hast sie nie geschrieben; `MCPServer` deklariert sie für dich. -Sieh es dir selbst an. Der `Client` des SDK nimmt das Server-Objekt direkt entgegen und verbindet sich **im Speicher** damit (kein Subprozess, kein Port): +Sieh es dir selbst an. Lass `server.py` in einem Terminal über HTTP laufen: -```python -import asyncio +```console +uv run mcp run server.py --transport streamable-http +``` -from mcp import Client +und richte aus einem anderen Terminal einen Client darauf: -from server import mcp +```python title="client.py" hl_lines="7-8" +--8<-- "docs_src/first_steps/tutorial001_client.py" +``` - -async def main() -> None: - async with Client(mcp) as client: - print(client.server_capabilities.model_dump(exclude_none=True)) - - -asyncio.run(main()) +```console +python client.py ``` ```text @@ -115,11 +113,12 @@ Dieses Dictionary sind die deklarierten **Capabilities** deines Servers. Es ist `MCPServer` bedient alle drei Primitive, also werden immer alle drei deklariert. -Achte darauf, was fehlt. `completions` (die automatische Vervollständigung von Argumenten für Ressourcen-Templates und Prompts) braucht einen Handler, den du schreibst. Dieser Server hat keinen, also fehlt die Capability, und ein wohlerzogener Client fragt gar nicht erst. Das ist die Regel für alles Optionale: Registriere das Ding, und die Capability erscheint; **[Vervollständigungen](../servers/completions.md)** zeigt es. +Achte darauf, was fehlt. `completions` (die automatische Vervollständigung von Argumenten für Ressourcen-Templates und Prompts) braucht einen Handler, den du schreibst. Dieser Server hat keinen, also fehlt die Capability, und ein Client, der sich korrekt verhält, fragt gar nicht erst. Das ist die Regel für alles Optionale: Registriere das Ding, und die Capability erscheint; **[Vervollständigungen](../servers/completions.md)** zeigt es. !!! info - `Client(mcp)` ist derselbe In-Memory-Client, mit dem jedes Beispiel in dieser Dokumentation - getestet wird, und so testest du auch deine. Er bekommt eine ganze Seite: **[Testen](testing.md)**. + Dieses `client.py` ist ein vollständiger MCP-Client, und **[Der Client](../client/index.md)** ist seine Seite. + In einem Test sparst du dir das Terminal und den Port und übergibst `Client` das Server-Objekt + selbst: `Client(mcp)`. Auch das bekommt eine ganze Seite: **[Testen](testing.md)**. ## Was du nicht geschrieben hast {#what-you-did-not-write} @@ -128,7 +127,7 @@ Blick auf diese Seite zurück. Du hast drei kleine Python-Funktionen geschrieben * Ein JSON-Schema. `a: int, b: int` *ist* das Schema für `add`. * Einen Request-Handler. `tools/list`, `resources/read`, `prompts/get`: alles für dich bedient. * Eine Capability-Deklaration. `MCPServer` hat sie für dich erstellt. -* Eine Zeile Protokoll. Die Versionsaushandlung, das JSON-RPC-Framing, der Austausch der Capabilities: Das alles passierte in `mcp dev` und `Client(mcp)`, und du hast es nie gesehen. +* Eine Zeile Protokoll. Die Versionsaushandlung, das JSON-RPC-Framing, der Austausch der Capabilities: Das alles passierte in `mcp dev` und `client.py`, und du hast es nie gesehen. Dieses Verhältnis ist der ganze Sinn des SDK. @@ -139,6 +138,6 @@ Dieses Verhältnis ist der ganze Sinn des SDK. * Ein Dekorator pro Primitiv: `@mcp.tool()`, `@mcp.resource(uri)`, `@mcp.prompt()`. Name, Beschreibung und Schema kommen aus der Funktion. * Ein URI mit einem `{param}` ergibt ein Ressourcen-**Template**, das getrennt von konkreten Ressourcen aufgelistet wird. * Die **Capabilities** des Servers werden für dich deklariert, und ein Client fragt nur nach dem, was ein Server deklariert. -* `Client(mcp)` verbindet sich im Speicher mit dem Server-Objekt: deine Testumgebung vom ersten Tag an. +* `Client("http://localhost:8000/mcp")` spricht mit deinem laufenden Server. Übergib ihm stattdessen das Server-Objekt, `Client(mcp)`, und er ist deine Testumgebung vom ersten Tag an. Als Nächstes kommt **[Mit einem echten Host verbinden](real-host.md)**: dieser Server in Claude Desktop oder einer IDE, in echt. Danach **[Testen](testing.md)**: eine Seite, ein In-Memory-Client, und du musst nie raten, ob es funktioniert. Danach bekommt jedes Primitiv seine eigene Seite, angefangen mit dem, das das Modell steuert: **[Tools](../servers/tools.md)**. diff --git a/i18n/de/pages/get-started/testing.md b/i18n/de/pages/get-started/testing.md index f5501402..7accf716 100644 --- a/i18n/de/pages/get-started/testing.md +++ b/i18n/de/pages/get-started/testing.md @@ -1,13 +1,13 @@ --- translation: - sections: ['4926721070127497', c52a1de2b6b32f40, 8e792bf8c7489ec6, 627195f7159e24ef] + sections: [5d13c2f0ba42c0d2, c52a1de2b6b32f40, 8e792bf8c7489ec6, 38552ea228b0a04f] tool: 1 --- # Testen {#testing} -Das Python SDK bringt eine Klasse `Client` mit einem **In-Memory-Transport** mit: Übergib ihr dein Server-Objekt, und sie verbindet sich direkt damit. +Die Klasse `Client` des SDK – dieselbe, die sich mit einer URL verbindet oder einen Subprozess startet – verbindet sich auch **In-Memory**: Übergib ihr dein Server-Objekt, und sie spricht direkt mit ihm. -Kein Subprozess. Kein Port. Überhaupt kein Transport. Die Idee ist dieselbe wie bei FastAPIs `TestClient`. +Kein Subprozess. Kein Port. Nichts auf der Leitung. Die Idee ist dieselbe wie bei FastAPIs `TestClient`. ## Grundlegende Verwendung {#basic-usage} @@ -97,12 +97,12 @@ statt der bereinigten. Lass es in Tests eingeschaltet. In Produktionscode hat es keine Bedeutung. -## Standardmäßig im selben Prozess {#in-process-by-default} +## Standardmäßig generationsneutral {#era-neutral-by-default} !!! note `Client(mcp)` verbindet sich im selben Prozess und ist standardmäßig **generationsneutral** (era-neutral): Er prüft den Server und wählt den passenden Protokollpfad. Lege `mode="legacy"` fest, wenn dein Test Legacy-spezifische - Semantik prüft (Sampling- oder Elicitation-Push – Elicitation ist die Rückfrage bei der Person am Host –, `message_handler`), und lass `raise_exceptions=True` + Semantik prüft (Push von Sampling oder Elicitation – Rückfrage bei der Person am Host –, `message_handler`), und lass `raise_exceptions=True` dort weg: Eine Legacy-Verbindung bereinigt von vornherein nie, und das Flag löst den Fehler erneut in der Server-Task aus statt in deinem Test. diff --git a/i18n/de/pages/handlers/multi-round-trip.md b/i18n/de/pages/handlers/multi-round-trip.md index 37737b09..159fd896 100644 --- a/i18n/de/pages/handlers/multi-round-trip.md +++ b/i18n/de/pages/handlers/multi-round-trip.md @@ -1,6 +1,6 @@ --- translation: - sections: [74011e683045eea9, 9b64cc175c18b6a9, 4b41be4824030397, e3b1502da786ec33, 71e41161f143c6a9, 9ec2c1eeb8c36378, 8dd027377d46448b, f81491125dcbfe8b] + sections: [74011e683045eea9, 9b64cc175c18b6a9, 4b41be4824030397, e3b1502da786ec33, 71e41161f143c6a9, 9ec2c1eeb8c36378, b47667184ca5b516, f81491125dcbfe8b] tool: 1 --- # Multi-Roundtrip-Requests {#multi-round-trip-requests} @@ -164,7 +164,7 @@ Der Low-Level-`Server` ist die Stufe ohne Extras: Anders als bei `MCPServer` wir ## Ein Ergebnis für 2026-07-28 {#a-2026-07-28-result} -`InputRequiredResult` gibt es nur bei Protokollversion **2026-07-28**. Der In-Memory-`Client(server)` handelt sie für dich aus; über die Leitung entdeckt `mode="auto"` sie. Nach dem Verbinden sagt dir `client.protocol_version`, was du bekommen hast. +`InputRequiredResult` gibt es nur bei Protokollversion **2026-07-28**. `mode="auto"`, der Standardwert von `Client`, entdeckt sie auf jeder Verbindung. Nach dem Verbinden sagt dir `client.protocol_version`, was du bekommen hast. !!! warning Eine Session vor 2026 hat keinen Platz für ein `InputRequiredResult`. Gibst du eines aus deinem Handler auf einer diff --git a/i18n/de/pages/handlers/progress.md b/i18n/de/pages/handlers/progress.md index cdefdfab..77e947a2 100644 --- a/i18n/de/pages/handlers/progress.md +++ b/i18n/de/pages/handlers/progress.md @@ -1,6 +1,6 @@ --- translation: - sections: [5315262fe26b33e1, 9d8e98840f1b78f0, 0284b215e85366c4, 8534d8dbb4053a70, 2966fac6fe697007] + sections: [5315262fe26b33e1, 9d8e98840f1b78f0, 52d6009a07e770ea, 8534d8dbb4053a70, 2966fac6fe697007] tool: 1 --- # Fortschritt {#progress} @@ -29,19 +29,17 @@ Drei Argumente, und du bestimmst, was sie bedeuten: Der Client meldet sich **pro Aufruf** an, indem er `progress_callback=` an `call_tool` übergibt: -```python title="client.py" hl_lines="7 16" +```python title="client.py" hl_lines="5 14" import anyio from mcp import Client -from server import mcp - async def show(progress: float, total: float | None, message: str | None) -> None: print(f"{message} ({progress}/{total})") async def main() -> None: - async with Client(mcp) as client: + async with Client("http://localhost:8000/mcp") as client: result = await client.call_tool( "import_catalog", {"urls": ["https://example.com/a.json", "https://example.com/b.json"]}, @@ -56,28 +54,31 @@ anyio.run(main) Der Callback ist eine `async`-Funktion, die genau das entgegennimmt, was der Server gemeldet hat: `progress`, `total`, `message`. !!! info - `Client(mcp)` verbindet sich direkt mit dem Server-Objekt, im Speicher – derselbe Client, auf dem die Seite - **[Testen](../get-started/testing.md)** aufbaut. `progress_callback` ist derselbe Parameter, egal welchen - Transport der `Client` nutzt; das *Timing*, das du gleich siehst, ist das der In-Memory-Verbindung. Sie führt - deinen Callback inline aus, sodass jede Meldung eintrifft, bevor `call_tool` zurückkehrt. Über einen echten - Transport liefern sich die Benachrichtigungen ein Rennen mit dem Ergebnis, und ein langsamer Callback kann noch - laufen, nachdem `call_tool` bereits zurückgekehrt ist. + `progress_callback` ist derselbe Parameter, egal was du `Client` übergeben hast: eine URL wie hier, ein + `StdioServerParameters`-Objekt oder das Server-Objekt in einem Test. Achte über einen echten Transport + allerdings auf das Timing. Jede Benachrichtigung wird für sich zugestellt, neben der Response, sodass ein + langsamer Callback noch laufen kann, nachdem `call_tool` bereits zurückgekehrt ist. Nur die + In-Process-Testverbindung führt den Callback inline aus und garantiert, dass jede Meldung vorher eintrifft. ### Ausprobieren {#try-it} -Lege `client.py` neben `server.py` und starte es: +Stelle `server.py` über HTTP bereit und starte dann den Client aus einem zweiten Terminal: + +```console +uv run mcp run server.py --transport streamable-http +``` ```console python client.py ``` ```text -Imported https://example.com/a.json (1/2) -Imported https://example.com/b.json (2/2) +Imported https://example.com/a.json (1.0/2.0) +Imported https://example.com/b.json (2.0/2.0) {'result': 'Imported 2 records.'} ``` -Jedes `await ctx.report_progress(...)` auf dem Server wurde zu einem Aufruf von `show` auf dem Client, in derselben Reihenfolge, und beide Zeilen wurden ausgegeben, **bevor** `call_tool` zurückkehrte. Fortschritt wird nicht ins Ergebnis gepackt; er streamt, während das Tool noch arbeitet. +Jedes `await ctx.report_progress(...)` auf dem Server wurde zu einem Aufruf von `show` auf dem Client, in derselben Reihenfolge. Fortschritt wird nicht ins Ergebnis gepackt. Er streamt, während das Tool noch arbeitet. !!! warning `progress_callback` gehört zum **Aufruf**, nicht zum `Client`. Es gibt kein Konstruktorargument dafür, diff --git a/i18n/de/pages/protocol-versions.md b/i18n/de/pages/protocol-versions.md index 887ed4ab..b4250730 100644 --- a/i18n/de/pages/protocol-versions.md +++ b/i18n/de/pages/protocol-versions.md @@ -1,6 +1,6 @@ --- translation: - sections: [478fd619e5f90ef8, aef094a00e44e248, bab8cbf3449fa7e9, df1809b15a58335b, 5f9d8c2336ed0239, f54974398e43ddef, b24443dd78584870] + sections: [d98e337bf28845e0, dd642acbba36f615, f547b3e76da19c94, 149513378588da5c, 4e149ed992046709, f54974398e43ddef, b24443dd78584870] tool: 1 --- # Protokollversionen {#protocol-versions} @@ -11,9 +11,17 @@ Server, die vor 2026-07-28 veröffentlicht wurden, eröffnen jede Verbindung mit Darum musst du dich fast nie kümmern, denn `Client` handelt das für dich aus. Diese Seite behandelt das eine Konstruktorargument, das es steuert, `mode=`, und die drei Fälle, in denen du es änderst. +Jedes Snippet auf dieser Seite ist eine `client.py`, die mit der Bookshop-`server.py` aus **[Der Client](client/index.md)** spricht. Starte diesen Server in einem Terminal: + +```console +uv run mcp run server.py --transport streamable-http +``` + +Führe dann jedes Snippet in einem zweiten Terminal mit `python client.py` aus. + ## `mode="auto"` {#modeauto} -```python title="client.py" hl_lines="14-15" +```python title="client.py" hl_lines="7-8" --8<-- "docs_src/protocol_versions/tutorial001.py" ``` @@ -31,13 +39,14 @@ So oder so bist du am Ende verbunden, und `client.protocol_version` sagt dir, we Das ist das ganze Feature. Ein `Client`, Server jeder Generation, keine Verzweigung in deinem Code. !!! info - `MCPServer` beantwortet `server/discover` auf jedem Transport – In-Memory, stdio, Streamable - HTTP –, sodass `auto` gegen deinen eigenen Server immer bei `2026-07-28` landet. Der Fallback - greift nur gegen einen echten Server von vor 2026, und genau dann willst du ihn auch. + `MCPServer` beantwortet `server/discover` auf jedem Transport – Streamable HTTP, stdio und die + In-Process-Verbindung, die deine Tests verwenden –, sodass `auto` gegen deinen eigenen Server + immer bei `2026-07-28` landet. Der Fallback greift nur gegen einen echten Server von vor 2026, + und genau dann willst du ihn auch. ## `mode="legacy"` {#modelegacy} -```python title="client.py" hl_lines="14" +```python title="client.py" hl_lines="7" --8<-- "docs_src/protocol_versions/tutorial002.py" ``` @@ -61,7 +70,7 @@ Bei 2026-07-28 ist er weg. Der Server *gibt* seine Fragen *zurück*, und du wied `mode` akzeptiert auch den String einer modernen Protokollversion. Heute ist diese Menge genau `["2026-07-28"]`. -```python title="client.py" hl_lines="14" +```python title="client.py" hl_lines="7" --8<-- "docs_src/protocol_versions/tutorial003.py" ``` @@ -94,7 +103,7 @@ Die Sondierung ist billig, aber sie bleibt ein Roundtrip, den du bei jedem Neuve Also heb sie auf. Nach einer `auto`-Verbindung enthält `client.session.discover_result` genau das `DiscoverResult`, das der Server gesendet hat: seine `supported_versions`, seine `capabilities`, seine `instructions` und die Identität, die der Server in das `_meta` des Ergebnisses gestempelt hat. Gib es beim nächsten Mal als `prior_discover=` zurück: -```python title="client.py" hl_lines="15 17" +```python title="client.py" hl_lines="8 10" --8<-- "docs_src/protocol_versions/tutorial004.py" ``` diff --git a/i18n/de/pages/run/asgi.md b/i18n/de/pages/run/asgi.md index 23750a5c..ab304496 100644 --- a/i18n/de/pages/run/asgi.md +++ b/i18n/de/pages/run/asgi.md @@ -1,6 +1,6 @@ --- translation: - sections: [1062ef792791488a, 4be2b831547184a9, 374b049e770385f2, b72f6947089e6de0, b172c9db7831bb31, 70b9ece244ca1b0c, cba78e052898c3f6, f06bdb541cb0b469, fb82d526320b7cc3] + sections: [1062ef792791488a, 4be2b831547184a9, 374b049e770385f2, b72f6947089e6de0, b172c9db7831bb31, 10394c6f16601638, cba78e052898c3f6, f06bdb541cb0b469, 6dc898ccc5a903f9] tool: 1 --- # In eine bestehende App einbinden {#add-to-an-existing-app} @@ -99,7 +99,7 @@ Das abschließende `/mcp` ist `streamable_http_path`. Setze es auf `"/"`, und da --8<-- "docs_src/asgi/tutorial004.py" ``` -Jetzt verbinden sich Clients mit `/notes`, nicht mit `/notes/mcp`. +Jetzt verbinden sich Clients mit `/notes/`, nicht mit `/notes/mcp`. ## CORS für Browser-Clients {#cors-for-browser-clients} @@ -142,4 +142,4 @@ Ein browserbasierter Client braucht zwei Erlaubnisse von dir: seine MCP-Request- * Browser-Clients brauchen CORS: `allow_headers` für die `Mcp-*`-Request-Header, `expose_headers=["Mcp-Session-Id"]` für die Response. * `@mcp.custom_route()` fügt einfache, nicht authentifizierte HTTP-Endpunkte neben `/mcp` hinzu. -Sobald der Server unter einer echten URL erreichbar ist, verbindet sich **[Der Client](../client/index.md)** über diese URL mit ihm statt über ein Server-Objekt. +Sobald der Server unter einer echten URL erreichbar ist, verbindet sich **[Der Client](../client/index.md)** über diese URL mit ihm. diff --git a/i18n/de/pages/run/authorization.md b/i18n/de/pages/run/authorization.md index 3cd5d35a..d717103c 100644 --- a/i18n/de/pages/run/authorization.md +++ b/i18n/de/pages/run/authorization.md @@ -1,6 +1,6 @@ --- translation: - sections: [d62c13457fc4a534, 80e73abaca6e0652, d1dc4c54cd00ec9c, 14ad3bc7904036bb, 5225f127bc1b9c77, fe1626fdd5aad1da, 4556cb7ea1a04a31] + sections: [d62c13457fc4a534, 80e73abaca6e0652, 128a492d18295f64, 14ad3bc7904036bb, 54a6697833fcc1d9, fe1626fdd5aad1da, 811d083c1da8bcf6] tool: 1 --- # Autorisierung {#authorization} @@ -23,12 +23,12 @@ Das ist das ganze Dreieck. Alles auf dieser Seite betrifft den mittleren Punkt. Das SDK hat keine Meinung dazu, wie ein gültiges Token aussieht. Das sagst du ihm, indem du **`TokenVerifier`** implementierst: -```python title="server.py" hl_lines="12-14 19-24" +```python title="server.py" hl_lines="14-16 21-27" --8<-- "docs_src/authorization/tutorial001.py" ``` * `TokenVerifier` ist ein Protokoll mit einer einzigen asynchronen Methode. `verify_token` bekommt das rohe Token aus dem `Authorization`-Header und gibt ein **`AccessToken`** zurück, wenn es gültig ist, und `None`, wenn nicht. Mehr gibt es nicht zu implementieren. -* Dieser hier schlägt das Token in einer Tabelle nach. Ein echter prüft eine JWT-Signatur oder ruft den Token-Introspection-Endpunkt des Authorization Servers auf. Dieser Code gehört dir; das SDK ruft ihn nur auf. +* Dieser hier schlägt das Token in einer Tabelle nach; jeder Eintrag hält fest, für welche Ressource es ausgestellt wurde. Ein echter prüft eine JWT-Signatur oder ruft den Token-Introspection-Endpunkt des Authorization Servers auf und meldet in `AccessToken.resource`, für wen das Token ausgestellt wurde (sein `aud`). Dieser Code gehört dir; das SDK ruft ihn nur auf. * `token_verifier=` und `auth=` treten immer gemeinsam auf. Übergibst du das eine ohne das andere, löst `MCPServer(...)` einen `ValueError` aus, bevor auch nur ein Request bedient wird. `AuthSettings` ist das öffentliche Gesicht deines Resource Servers: @@ -36,6 +36,10 @@ Das SDK hat keine Meinung dazu, wie ein gültiges Token aussieht. Das sagst du i * `issuer_url`: der Authorization Server, der deine Tokens ausstellt. * `resource_server_url`: die öffentliche URL dieses MCP-Endpunkts. Sie benennt, für *welche* Ressource ein Token gilt, und unter ihr liegt das Discovery-Dokument. * `required_scopes`: jedes Token muss alle davon tragen. +* `validate_token_resource`: lehnt jedes Token ab, dessen `AccessToken.resource` nicht `resource_server_url` ist. Lässt du es ungesetzt, während `resource_server_url` gesetzt ist, gibt es eine Warnung (`MCPDeprecationWarning`) und es verhält sich wie `False`; ab 3.0 ist `True` der Standardwert für Resource Server. + * Schalte es ein, wenn dein Authorization Server Tokens an die `resource` bindet, die der Client angefordert hat – MCP-Clients senden sie immer. Halte `resource_server_url` exakt auf der URL, mit der sich Clients verbinden. + * Lass es aus, wenn dein Authorization Server eigene Audience-Bezeichner verwendet (einen Auth0-API-Identifier, eine Entra-Application-ID), und prüfe `aud` stattdessen in deinem Verifier: Gib für ein Token, das nicht für diesen Server bestimmt ist, `None` zurück. + * Ist `aud` eine Liste, trage in `resource` den Eintrag ein, der `resource_server_url` entspricht. !!! tip `examples/servers/simple-auth/` im SDK-Repository enthält einen `IntrospectionTokenVerifier`, der den @@ -91,11 +95,11 @@ Schick ein `GET` an diesen Well-Known-Pfad, und du bekommst **Protected Resource In jedem Handler ist **`get_access_token()`** das `AccessToken`, das dein Verifier für den aktuellen Request zurückgegeben hat: -```python title="server.py" hl_lines="4 32-35" +```python title="server.py" hl_lines="4 35-38" --8<-- "docs_src/authorization/tutorial002.py" ``` -* Es funktioniert in Tools, Ressourcen und Prompts, und du musst nichts herumreichen: Die Auth-Middleware speichert es pro Request in einer Context-Variablen. +* Es funktioniert in Tools, Ressourcen und Prompts, und du musst nichts herumreichen: Die Auth-Middleware speichert es pro Request in einer Kontextvariablen. * Du bekommst **dasselbe Objekt zurück, das dein Verifier gebaut hat**: `client_id`, `scopes`, `subject`, `expires_at` und alle zusätzlichen `claims`, die du angehängt hast. Das ist der Ansatzpunkt für Regeln pro Tool: Lies die Scopes und lehne ab. * Außerhalb eines authentifizierten HTTP-Requests gibt es `None` zurück. In-Memory und über `stdio` ist es immer `None`. @@ -125,6 +129,6 @@ Ein Authorization Server kann statt einer Person, die sich durch einen Consent-S * `token_verifier=` und `auth=AuthSettings(issuer_url=..., resource_server_url=..., required_scopes=[...])` treten immer gemeinsam auf. * Das SDK veröffentlicht Protected Resource Metadata nach [RFC 9728](https://datatracker.ietf.org/doc/html/rfc9728) unter `/.well-known/oauth-protected-resource/...` und beantwortet nicht authentifizierte Requests mit einer 401, deren `WWW-Authenticate`-Header darauf zeigt. Das ist die ganze Discovery-Geschichte. * `get_access_token()` in jedem Handler sagt dir, wer aufruft. -* Autorisierung ist eine HTTP-Angelegenheit. `stdio` und der In-Memory-Client bekommen sie nie zu sehen. +* Autorisierung ist eine HTTP-Angelegenheit. `stdio` und der In-Memory-Test-Client bekommen sie nie zu sehen. Die Client-Hälfte (deinen Authorization Server finden und das Token für dich holen) steht unter **[OAuth-Clients](../client/oauth-clients.md)**. Und ein Client, der eine Identität *behauptet*, statt eine Person danach zu fragen, steht unter **[Identity Assertion](../client/identity-assertion.md)**. diff --git a/i18n/de/pages/run/deploy.md b/i18n/de/pages/run/deploy.md index ffb06c04..66e451d1 100644 --- a/i18n/de/pages/run/deploy.md +++ b/i18n/de/pages/run/deploy.md @@ -1,6 +1,6 @@ --- translation: - sections: [28221886b198784f, f88ea1f1614f3a1d, ce926d686730b6d0, 3be24f8ad8bb5ab9, 3fad24032b2224ff, f25a7f860e579ecb, e758745df6fb7b0a] + sections: [28221886b198784f, f88ea1f1614f3a1d, 2e76f5cb9df15042, ce926d686730b6d0, 3be24f8ad8bb5ab9, 3fad24032b2224ff, f25a7f860e579ecb, 697b01d95080880d] tool: 1 --- # Bereitstellen und skalieren {#deploy-scale} @@ -47,6 +47,22 @@ Hinter einem echten Hostnamen bereitgestellt, weist genau dieser Standard **jede bereitgestellter Server, der jede Verbindung ablehnt, ist bis zum Beweis des Gegenteils eine Host-Allowlist. Auch **[Fehlerbehebung](../troubleshooting.md)** fängt hier an. +## Hinter einem TLS-terminierenden Proxy {#behind-a-tls-terminating-proxy} + +Wenn TLS an einem Proxy endet (einem Ingress, einem Load Balancer, Caddy, nginx) und uvicorn dahinter unverschlüsseltes HTTP ausliefert, weise uvicorn an, den `X-Forwarded-*`-Headern des Proxys zu vertrauen: + +```console +uvicorn server:app --proxy-headers --forwarded-allow-ips='' +``` + +Ohne das glaubt die App, sie werde über `http://` ausgeliefert, und jeder Redirect, den sie auslöst (der übliche ist `/mcp` → `/mcp/`), zeigt auf `http://…`. Der Python-Client weigert sich, von einem HTTPS-Endpunkt zu unverschlüsseltem HTTP zu folgen, und sagt das auch: + +```text +MCPError: Redirect to http://mcp.example.com/mcp/ not followed: it would downgrade this HTTPS endpoint to plain HTTP. +``` + +Die clientseitige Notlösung ist, genau die URL zu konfigurieren, die der Server ausliefert (`https://mcp.example.com/mcp/`, Schrägstrich inklusive), sodass kein Redirect passiert. Die eigentliche Lösung ist das Flag oben. `FORWARDED_ALLOW_IPS` ist die Schreibweise als Umgebungsvariable; `*` vertraut jedem Hop, was nur richtig ist, wenn nichts außer dem Proxy uvicorn erreichen kann. + ## Worker – und wer sticky sein muss {#workers-and-who-has-to-be-sticky} Sobald der Hostname antwortet, stellst du mehr als einen Worker dahinter. Dafür gibt es keinen Schalter im SDK; du skalierst eine Starlette-App wie jede andere ASGI-App, indem du das Objekt an etwas übergibst, das forken kann: @@ -170,6 +186,7 @@ Ein `MCPServer` ist eine Protokollimplementierung, kein Anwendungsserver. Die De ## Zusammenfassung {#recap} * Ohne weitere Konfiguration beantwortet die App nur Requests an localhost. `transport_security=TransportSecuritySettings(allowed_hosts=[...], allowed_origins=[...])` ist die Schranke zum Livegang: Bis du es übergibst, ist jeder Request hinter einem echten Hostnamen ein `421`, und der Grund steht nur im Log des Servers. +* Hinter einem TLS-terminierenden Proxy startest du uvicorn mit `--proxy-headers --forwarded-allow-ips=...`, sonst zeigen seine Redirects auf `http://`, und der Client lehnt sie ab. * Auf 2026-07-28 gibt es keine Session und nichts, woran ein Load Balancer sticky sein könnte. `stateless_http=True` ist ein reiner Legacy-Schalter, weil ein moderner Request geroutet und beantwortet ist, bevor dieses Flag überhaupt gelesen wird. * Der Standardschlüssel für `requestState` ist `os.urandom(32)`, pro Prozess erzeugt. Ein Multi-Roundtrip-Retry, der bei einem anderen Worker landet, scheitert mit `-32602` *„Invalid or expired requestState“*. * Die Lösung ist `RequestStateSecurity(keys=[...])` **und** derselbe Servername auf jeder Instanz. Der Name ist der Standard-Audience-Claim des Tokens. Dieselben Schlüssel, derselbe Name. diff --git a/i18n/de/pages/run/index.md b/i18n/de/pages/run/index.md index 54e65f7e..031fbdd5 100644 --- a/i18n/de/pages/run/index.md +++ b/i18n/de/pages/run/index.md @@ -1,6 +1,6 @@ --- translation: - sections: [fea8d769ff9edeba, ce8e2ad42f29ef71, 0d705efb19cf99c2, 2fd7cf825e6d2b2c, 9adc400e8c88e854, 318893ad8e2e9924, 6b63ab96b34476c0] + sections: [fea8d769ff9edeba, ce8e2ad42f29ef71, 0d705efb19cf99c2, 9fd2357154a5b7e7, 9adc400e8c88e854, 318893ad8e2e9924, 6b63ab96b34476c0] tool: 1 --- # Den Server betreiben {#running-your-server} @@ -75,6 +75,11 @@ Jeder Transport hat eigene Keyword-Argumente, alle an `run()`: * `max_request_body_size`: größter akzeptierter Request-Body in Bytes. Standardwert 4 MiB; größere Requests erhalten HTTP 413, bevor geparst oder eine Session angelegt wird. Erhöhe ihn nur, wenn legitime MCP-Nachrichten diese Größe überschreiten. +* `session_idle_timeout`: Sekunden, die eine Legacy-Session ohne laufenden Request verbringen darf, bevor der + Server sie schließt. Standardwert 1800. `None` schaltet den Timeout ab. Siehe + [Lebensdauer und Limits von Sessions](legacy-clients.md#session-lifetime-and-limits). +* `max_sessions`: wie viele Legacy-Sessions ein Prozess gleichzeitig hält. Standardwert 10 000. `None` + hebt das Limit auf. Im selben Abschnitt beschrieben. * `event_store`, `retry_interval`, `transport_security`: Wiederaufnahme und Schutz vor DNS-Rebinding. Sie können warten, bis du anderswo als auf localhost bereitstellst; **[Bereitstellen und skalieren](deploy.md)** behandelt `transport_security`. !!! warning diff --git a/i18n/de/pages/run/legacy-clients.md b/i18n/de/pages/run/legacy-clients.md index 63fdbca6..e0606aee 100644 --- a/i18n/de/pages/run/legacy-clients.md +++ b/i18n/de/pages/run/legacy-clients.md @@ -1,6 +1,6 @@ --- translation: - sections: [3d1663c18edc824c, d4fd37009a13f03d, af9f398a5a8b679a, 470c2dd144294d69, 8e45827e6d24e8c8, 91dfd0ce98ebb03c] + sections: [3d1663c18edc824c, 90956965ae6a1ca1, af9f398a5a8b679a, 5ce83b1f9d88da62, 0d9b5d13fffc94e5, 8e45827e6d24e8c8, 91dfd0ce98ebb03c] tool: 1 --- # Legacy-Clients unterstützen {#serving-legacy-clients} @@ -22,15 +22,25 @@ Ein Legacy-Client ist also nichts, *wofür* du etwas baust. Er ist etwas, das si ## Ein Handler, beide Generationen {#one-handler-both-eras} -Hier ist ein Tool, das die Person am Host etwas fragen muss, und Clients beider Generationen, die es aufrufen: +Hier ist ein Tool, das die Person am Host etwas fragen muss: -```python title="server.py" hl_lines="24 37-38" +```python title="server.py" hl_lines="21" --8<-- "docs_src/legacy_clients/tutorial001.py" ``` `reserve` braucht eine Sache, die das Modell nicht geliefert hat: wie viele Exemplare. Mit `Annotated[..., Resolve(ask_quantity)]` deklariert ein Tool genau das (alles Weitere steht in **[Abhängigkeiten](../handlers/dependencies.md)**). Nichts in `reserve` nennt eine Version, prüft eine Capability oder verzweigt. -Die beiden Clients sind **gleichzeitig** offen, am selben `mcp`-Objekt. `mode="legacy"` führt den `initialize`-Handshake aus: genau die Verbindung, die ein Client von vor 2026 öffnet. Der andere nimmt den Standardwert und landet bei `2026-07-28`. +Stelle es über HTTP bereit, und hier sind Clients beider Generationen, die es aufrufen: + +```console +uv run mcp run server.py --transport streamable-http +``` + +```python title="client.py" hl_lines="14-15" +--8<-- "docs_src/legacy_clients/tutorial001_client.py" +``` + +Die beiden Clients sind **gleichzeitig** offen, gegen denselben laufenden Server. `mode="legacy"` führt den `initialize`-Handshake aus: genau die Verbindung, die ein Client von vor 2026 öffnet. Der andere nimmt den Standardwert und landet bei `2026-07-28`. Führe `python client.py` in einem zweiten Terminal aus: ```text 2025-11-25 {'result': "Reserved 2 of 'Dune'."} @@ -44,7 +54,7 @@ Es lohnt sich, beim *Wie* kurz innezuhalten, denn den beiden Clients wurde diese !!! tip Genau diese Portabilität über Generationen hinweg ist der Grund, *warum* `Resolve` die API ist, auf die du bauen solltest. Sein älterer Verwandter `ctx.elicit()` - (**[Elicitation](../handlers/elicitation.md)**, die Rückfrage bei der Person am Host) sendet + (**[Elicitation](../handlers/elicitation.md)** – Rückfrage bei der Person am Host) sendet immer nur `elicitation/create` und funktioniert deshalb immer nur auf einer Legacy-Verbindung. Auf einer `2026-07-28`-Verbindung schlägt der Aufruf fehl. Wenn ein Tool es noch verwendet, ist die Lösung die, die du oben siehst, und kein Versionscheck. @@ -65,6 +75,41 @@ Auf einem Worker ist das unsichtbar. Auf zweien ist es das ganze Problem: Ein Re verbindet), kein Session-Store. Es macht eine Session nie von einem anderen Prozess aus erreichbar. +## Lebensdauer und Grenzen von Sessions {#session-lifetime-and-limits} + +Eine Legacy-Session lebt nicht ewig, und ein Prozess hält nicht unbegrenzt viele davon. Zwei +Einstellungen steuern das. Beide sind Keyword-Argumente an `run()`, `streamable_http_app()` und +`Server.streamable_http_app()`. Moderne (`2026-07-28`-)Verbindungen und `stateless_http=True` +haben keine Sessions, also gilt keine der beiden Einstellungen für sie. + +| Einstellung | Standardwert | Was sie tut | Was der Client sieht | Abschalten mit | +|---|---|---|---|---| +| `session_idle_timeout` | `1800` (30 min) | Schließt eine Session, bei der so lange nichts in Bearbeitung war. | `404 Session not found`. Er muss erneut `initialize` senden. | `None` | +| `max_sessions` | `10_000` | Lehnt es ab, über diese Anzahl hinaus eine Session zu öffnen. Bestehende Sessions bleiben unberührt, und nichts wird verdrängt. | `503 Too many open sessions` mit JSON-RPC-Code `-32603`. | `None` | + +Was als „in Bearbeitung“ zählt: + +* Ein offener `GET`-Stream. Die SDK-Clients halten einen offen, also läuft die Session eines + verbundenen Clients nie ab. +* Ein Request, der noch beantwortet wird. Ein Tool-Aufruf, der länger läuft als das Timeout, wird + nicht unterbrochen, und der Countdown beginnt erst, wenn er fertig ist. +* Sonst nichts. Zwischen Requests läuft die Uhr. Jeder Request auf der Session setzt sie zurück, + `ping` eingeschlossen. Ist eine Session einmal abgelaufen, belebt nichts sie wieder. + +Ein Client, der seine Session mit `DELETE` beendet, gibt sie sofort frei. Dasselbe gilt für einen +Client, dessen eröffnender Request abgelehnt wurde. + +```python +mcp.run(transport="streamable-http", session_idle_timeout=None, max_sessions=50_000) +``` + +Beide Ereignisse erscheinen im Server-Log. Ein Ablauf steht als `Session idle timeout` auf +`INFO`. Ein abgelehntes Öffnen als `Refusing to open a new session: sessions are already open` +auf `WARNING`. + +Die Limits gelten pro Prozess. Mit vier Workern liegt die Obergrenze beim Vierfachen von +`max_sessions`, und jeder Worker lässt seine eigenen Sessions ablaufen. + ## Die eine Stellschraube: `stateless_http` {#the-one-knob-stateless_http} Wenn Stickiness ein Preis ist, den du nicht zahlen willst, gibt es genau eine Sache, die du ändern kannst. @@ -91,8 +136,8 @@ Zwei Dinge daran sind wichtiger als das, was es tut. !!! check Mach es absichtlich falsch. `reserve` ist genau das Tool, das eben beide Clients bedient hat. - Stelle es mit `stateless_http=True` bereit, verbinde dieselben zwei Clients über HTTP und rufe - es von jedem aus auf. + Stelle es mit `stateless_http=True` bereit, verbinde dieselben zwei Clients und rufe es von + jedem aus auf. Der moderne Client bekommt weiterhin `Reserved 2 of 'Dune'.` Der moderne Zweig hat sich nicht verändert. diff --git a/i18n/de/pages/troubleshooting.md b/i18n/de/pages/troubleshooting.md index 84c61bb8..dc98239c 100644 --- a/i18n/de/pages/troubleshooting.md +++ b/i18n/de/pages/troubleshooting.md @@ -1,18 +1,24 @@ --- translation: - sections: [2efaecdef109a5c5, fcacd3e66b8635a4, 25323d737dcf0261, 8a6e351ec756904d, 137454d469c867f5, 6392596bd6df54f0, 41126fa9c4fe432f, 480b6d7897e30ab4, d83bb682e708dde0, ebbed3449c499db4, 323ef84f6b4bebde, 30fd31be74169d9a, 656943c6cb567218, c2dc3b1007d2e987, 7cf5386b997d04e9, 0b59feed8384456e, 0cba47bae78d04eb, e4355f4c7cf4fb2e] + sections: [3d58228e81b99543, 170514ce901c4139, 17d61fad0a50d62b, 8a6e351ec756904d, 137454d469c867f5, 6392596bd6df54f0, 41126fa9c4fe432f, 480b6d7897e30ab4, d83bb682e708dde0, ebbed3449c499db4, 525cdf1755e29d4c, 30fd31be74169d9a, d2e88333d4f7841f, c2dc3b1007d2e987, d6eabf60cc366341, f798e815252852c2, 0cba47bae78d04eb, 2c218ba829abf74e] tool: 1 --- # Fehlerbehebung {#troubleshooting} Jede Überschrift auf dieser Seite ist der exakte Text eines Fehlers, den das SDK erzeugt, gefolgt davon, was er bedeutet, und der Lösung in einem Schritt. Suche die letzte Zeile deines Tracebacks (oder deines Server-Logs) hier mit der Seitensuche des Browsers und lies nur diesen Eintrag. -Mehrere Einträge laufen gegen diesen einen Server: ein Tool und eine Ressource mit Template, die beide bei einer Stadt, die sie nicht kennen, eine Exception auslösen: +Mehrere Einträge laufen gegen diesen einen Server. Ein Tool und eine Ressource mit Template, die beide bei einer Stadt, die sie nicht kennen, eine Exception auslösen: ```python title="server.py" --8<-- "docs_src/troubleshooting/tutorial001.py" ``` +Diese Einträge erreichen ihn unter `http://localhost:8000/mcp`, lass ihn also über HTTP laufen: + +```console +uv run mcp run server.py --transport streamable-http +``` + Die Fehler, die diese Seite zitiert, sind echt: Die Testsuite des SDK selbst reproduziert jeden einzelnen. ## `ExceptionGroup: unhandled errors in a TaskGroup (1 sub-exception)` {#exceptiongroup-unhandled-errors-in-a-taskgroup-1-sub-exception} @@ -23,7 +29,7 @@ Das ist kein MCP-Fehler. Es ist Rauschen von anyio, und dein eigentlicher Fehler ```python async def main() -> None: - async with Client(mcp) as client: + async with Client("http://localhost:8000/mcp") as client: await client.read_resource("weather://Atlantis") ``` @@ -49,7 +55,7 @@ Damit machst du zwei Dinge: ```python async def main() -> None: - async with Client(mcp) as client: + async with Client("http://localhost:8000/mcp") as client: try: await client.read_resource("weather://Atlantis") except MCPError as e: @@ -67,7 +73,7 @@ async def main() -> None: ```python async def main() -> None: - client = Client(mcp) + client = Client("http://localhost:8000/mcp") tools = await client.list_tools() # RuntimeError ``` @@ -75,7 +81,7 @@ Betritt den Kontextmanager. `__aenter__` ist die Verbindung: ```python async def main() -> None: - async with Client(mcp) as client: + async with Client("http://localhost:8000/mcp") as client: tools = await client.list_tools() ``` @@ -251,7 +257,7 @@ app = Starlette(routes=[Mount("/", app=mcp.streamable_http_app())], lifespan=lif ## `MCPError: Session not found` {#mcperror-session-not-found} -Der Server erkennt die `Mcp-Session-Id`, die dein Client gesendet hat, nicht – fast immer, weil der Server **neu gestartet** wurde (oder du zu einer anderen Instanz geroutet wurdest). Sessions leben im Speicher dieses einen Prozesses. +Der Server erkennt die `Mcp-Session-Id`, die dein Client gesendet hat, nicht. Entweder wurde der Server **neu gestartet** (oder du wurdest zu einer anderen Instanz geroutet), oder die Session ist **abgelaufen**, weil für die Dauer von `session_idle_timeout` – standardmäßig 30 Minuten – nichts unterwegs war. Siehe [Lebensdauer und Limits von Sessions](run/legacy-clients.md#session-lifetime-and-limits). Sessions leben im Speicher dieses einen Prozesses. Es gibt keinen Server-Bug zu finden. Die HTTP-Response ist ein `404`, dessen Body JSON-RPC *ist*, deshalb zeigt dir der Python-`Client` – anders als beim `421` oben – diese hier wörtlich: @@ -261,9 +267,9 @@ Es gibt keinen Server-Bug zu finden. Die HTTP-Response ist ein `404`, dessen Bod Die Lösung ist, dich neu zu verbinden: Verlasse den `async with Client(...)`-Block und betritt einen neuen, der eine frische Session aushandelt. Für einen langlebigen Client heißt das, `MCPError` um deine Aufrufe herum abzufangen und bei dieser Meldung neu zu verbinden, statt es in einer toten Session erneut zu versuchen. -Passiert es *ohne* Neustart, betreibst du mehr als einen Worker ohne Sticky Sessions: Jeder Worker hält seine eigene Session-Tabelle, sodass ein Request, der zum falschen geroutet wird, hier landet. **[Bereitstellen und skalieren](run/deploy.md)** und **[Legacy-Clients unterstützen](run/legacy-clients.md)** behandeln dieses Thema und seine beiden Lösungen (Sticky Routing oder `stateless_http=True`). +Passiert es *ohne* Neustart und ohne dass der Client so lange still war, betreibst du mehr als einen Worker ohne Sticky Sessions: Jeder Worker hält seine eigene Session-Tabelle, sodass ein Request, der zum falschen geroutet wird, hier landet. **[Bereitstellen und skalieren](run/deploy.md)** und **[Legacy-Clients unterstützen](run/legacy-clients.md)** behandeln dieses Thema und seine beiden Lösungen (Sticky Routing oder `stateless_http=True`). -Für alle, die den Server betreiben, lautet die passende Log-Zeile `Rejected request with unknown or expired session ID: `. Sie wird auf `INFO` geloggt, ist also bei der üblichen `WARNING`-Schwelle unsichtbar. Sie direkt nach einem Deployment stoßweise zu sehen ist normal; jeder verbundene Client verbindet sich neu. +Für alle, die den Server betreiben, lautet die passende Log-Zeile `Rejected request with unknown or expired session ID: `. Sie wird auf `INFO` geloggt, ist also bei der üblichen `WARNING`-Schwelle unsichtbar. Sie direkt nach einem Deployment stoßweise zu sehen ist normal; jeder verbundene Client verbindet sich neu. Ist die Session stattdessen abgelaufen, steht vor dieser Zeile `Session idle timeout`, ebenfalls auf `INFO`. ## `MCPError: Method not found` {#mcperror-method-not-found} @@ -275,7 +281,13 @@ Eines erzeugt diesen Fehler **nicht**, obwohl es ein Request ist, den das modern Dein Server möchte die Person am Host etwas fragen, und dieser Client hat nie gesagt, dass man ihn fragen kann. -Ein Resolver für Elicitation (Rückfrage bei der Person am Host) lehnt von vornherein ab, wenn der verbundene Client keine Form-Elicitation deklariert hat, und `e.error.data` nennt genau, was fehlt: +Dieses Bistro fragt über einen Resolver zurück, bevor es bucht: + +```python title="server.py" hl_lines="15-17 21" +--8<-- "docs_src/troubleshooting/tutorial007.py" +``` + +Betreibe es anstelle des Weather-Servers und rufe `book_table` von einem Client aus auf, der keinen `elicitation_callback` übergeben hat. Der Resolver lehnt von vornherein ab, weil der verbundene Client nie Form-Elicitation (Rückfrage bei der Person am Host) deklariert hat, und `e.error.data` nennt genau, was fehlt: ```json { @@ -289,7 +301,7 @@ Ein Resolver für Elicitation (Rückfrage bei der Person am Host) lehnt von vorn ```python async def main() -> None: - async with Client(mcp, elicitation_callback=handle_elicitation) as client: + async with Client("http://localhost:8000/mcp", elicitation_callback=handle_elicitation) as client: result = await client.call_tool("book_table", {"date": "Friday"}) ``` @@ -315,14 +327,14 @@ Du siehst diese bei `ctx.elicit()` auf einer Legacy-Verbindung, und auf jeder be Dein Handler hat versucht, den Client mitten im Request zu erreichen, auf einer Verbindung, deren Aufruf keinen Rückkanal (back-channel) hat, der einen Request vom Server tragen kann. Drei Server-Konfigurationen bringen einen Aufruf in diese Lage. -**Eine `2026-07-28`-Verbindung: jeder Transport, immer.** Das moderne Protokoll kennt überhaupt keine vom Server initiierten Requests, deshalb weigert sich der Server, bevor irgendetwas gesendet wird. `ctx.elicit()` innerhalb eines Tools ist der klassische Weg, dem zu begegnen (schon beim allerersten In-Memory-Test, denn `Client(server)` handelt ungefragt `2026-07-28` aus), und `elicitation_callback=` zu übergeben ändert nichts, weil nie ein Request beim Client ankommt, den er beantworten könnte: +**Eine `2026-07-28`-Verbindung: jeder Transport, immer.** Das moderne Protokoll kennt überhaupt keine vom Server initiierten Requests, deshalb weigert sich der Server, bevor irgendetwas gesendet wird. `ctx.elicit()` innerhalb eines Tools ist der klassische Weg, dem zu begegnen, meist im allerersten In-Memory-**[Test](get-started/testing.md)** dieses Tools, denn `Client(mcp)` handelt ungefragt `2026-07-28` aus. `elicitation_callback=` zu übergeben ändert nichts, weil nie ein Request beim Client ankommt, den er beantworten könnte: ```python title="server.py" hl_lines="16" --8<-- "docs_src/troubleshooting/tutorial006.py" ``` ```python -async def main() -> None: +async def test_book_table() -> None: async with Client(mcp) as client: await client.call_tool("book_table", {"date": "Friday"}) ``` @@ -364,7 +376,7 @@ Der Server konnte das `requestState`-Token, das dein Client zurückgespielt hat, ```python async def main() -> None: - async with Client(mcp) as client: + async with Client("http://localhost:8000/mcp") as client: await client.call_tool("forecast", {"city": "London"}, request_state="round-1-from-worker-a") ``` @@ -418,7 +430,7 @@ mcp = MCPServer("Weather", request_state_security=RequestStateSecurity(keys=[key * `Tool already exists:` im Server-Log ist das einzige Zeichen, dass zwei gleichnamige Tools zu einem zusammengefallen sind. * Ein 421, drei Schreibweisen: `Server returned an error response` (der Python-`Client`), `421 Misdirected Request` / `Invalid Host header` (alles andere), `Invalid Host header: ` (das Server-Log). Lösung: `transport_security=TransportSecuritySettings(allowed_hosts=[...])`. * `Task group is not initialized` -> eine eingehängte App, deren Host-Lifespan nie `mcp.session_manager.run()` betreten hat. -* `Session not found` -> der Server wurde neu gestartet; verbinde dich neu. +* `Session not found` -> der Server wurde neu gestartet oder die Session ist abgelaufen (`session_idle_timeout`); verbinde dich neu. * `Cannot send 'elicitation/create': ... no back-channel ...` -> `ctx.elicit()` braucht einen Kanal vom Server zum Client: Eine `2026-07-28`-Verbindung hat nie einen, `stateless_http=True` nimmt den Legacy-Kanal weg, und `json_response=True` nimmt den Request-gebundenen weg. Verwende einen Resolver (ein Legacy-Client braucht außerdem einen Server, der den Kanal behält). Sein Nachbar `Method not found` ist ein Request für eine Methode, die die Protokollrevision der anderen Seite nicht hat. * `Client did not declare the form elicitation capability ...` und `Elicitation not supported` -> dem Client fehlt `elicitation_callback=`. * `Invalid or expired requestState` sagt auf der Leitung nie, warum. Das Server-Log schon; `unknown key` heißt: Teile `RequestStateSecurity(keys=[...])` über alle Worker hinweg. diff --git a/i18n/de/pages/whats-new.md b/i18n/de/pages/whats-new.md index a45f5a59..44b57adf 100644 --- a/i18n/de/pages/whats-new.md +++ b/i18n/de/pages/whats-new.md @@ -1,6 +1,6 @@ --- translation: - sections: [cfe01c0c5863dfa2, 1c58c5cfcc37d455, a7392996acf1ad8f, 875eb2889263424e] + sections: [cfe01c0c5863dfa2, 0dbb68d8b210177b, 80cf193023af3ed4, 875eb2889263424e] tool: 1 --- # Was ist neu in v2 {#whats-new-in-v2} @@ -42,11 +42,11 @@ Nicht alles, was ein Tool braucht, sollte vom Modell kommen. Neu in v2: Ein Tool v1 gab dir drei verschachtelte Schichten: einen Transport-Kontextmanager, der rohe Streams liefert, eine darum gewickelte `ClientSession` und ein von Hand aufgerufenes `await session.initialize()`. v2 hat ein einziges Objekt: -```python title="client.py" hl_lines="14-18" ---8<-- "docs_src/client/tutorial001.py" +```python title="client.py" hl_lines="7-11" +--8<-- "docs_src/client/tutorial001_client.py" ``` -`Client` nimmt ein Server-Objekt (im Speicher, ohne Transport: das ist der Testansatz), eine URL (Streamable HTTP), ein `StdioServerParameters` (ein stdio-Subprozess) oder einen beliebigen anderen Transport-Kontextmanager wie `sse_client(...)`. Das Betreten von `async with` verbindet und handelt die Protokollversion aus, welche Generation der Server auch spricht; `client.server_capabilities` und `client.protocol_version` sind danach einfach da, ebenso `client.server_info`, wenn der Server sich zu erkennen gibt (das ist jetzt `Implementation | None`, weil die Identität in der 2026er-Generation optional ist). Die Sampling- und Elicitation-Callbacks, die du in v1 registriert hast, funktionieren weiter (ihre Bodies sehen dieselbe Umbenennung der Attribute auf snake_case wie alles andere auf dieser Seite), sie beantworten jetzt außerdem die Requests-in-Results im 2026er-Stil (unten), und sie laufen nebenläufig statt nacheinander. `ClientSession` liegt für alle, die die Low-Level-Oberfläche wollen, weiterhin darunter, und `client.session` reicht sie dir; auch sie hat sich bewegt (sie läuft auf der neuen Dispatcher-Engine, und einige ihrer eigenen Signaturen haben sich geändert), lies also den **[Migrationsleitfaden](migration.md#clientsession-now-runs-on-jsonrpcdispatcher-basesession-removed)**, bevor du hinabsteigst. +`Client` nimmt eine URL (Streamable HTTP), ein `StdioServerParameters` (ein stdio-Subprozess), einen beliebigen anderen Transport-Kontextmanager wie `sse_client(...)` oder, in Tests, das Server-Objekt selbst (im Speicher, ohne Transport). Das Betreten von `async with` verbindet und handelt die Protokollversion aus, welche Generation der Server auch spricht; `client.server_capabilities` und `client.protocol_version` sind danach einfach da, ebenso `client.server_info`, wenn der Server sich zu erkennen gibt (das ist jetzt `Implementation | None`, weil die Identität in der 2026er-Generation optional ist). Die Sampling- und Elicitation-Callbacks, die du in v1 registriert hast, funktionieren weiter (ihre Bodies sehen dieselbe Umbenennung der Attribute auf snake_case wie alles andere auf dieser Seite), sie beantworten jetzt außerdem die Requests-in-Results im 2026er-Stil (unten), und sie laufen nebenläufig statt nacheinander. `ClientSession` liegt für alle, die die Low-Level-Oberfläche wollen, weiterhin darunter, und `client.session` reicht sie dir; auch sie hat sich bewegt (sie läuft auf der neuen Dispatcher-Engine, und einige ihrer eigenen Signaturen haben sich geändert), lies also den **[Migrationsleitfaden](migration.md#clientsession-now-runs-on-jsonrpcdispatcher-basesession-removed)**, bevor du hinabsteigst. **[Der Client](client/index.md)** stellt ihn vor, **[Client-Transporte](client/transports.md)** behandelt die vier Verbindungsformen, **[Client-Callbacks](client/callbacks.md)** die Callbacks selbst, und **[Testen](get-started/testing.md)** zeigt das In-Memory-Muster, das den Helfer `create_connected_server_and_client_session()` aus v1 ersetzt. @@ -113,7 +113,7 @@ async def call_tool(name: str, arguments: dict[str, Any]) -> list[types.ContentB Das Beispiel ist das Muster. Allgemeiner: Jeder Handler hat dieselbe Form, mit typisierten Params hinein und einem vollständigen Result-Typ hinaus; die alte jsonschema-Prüfung der Tool-Argumente ist entfernt; eine Exception ist ein Protokollfehler, nie ein Tool-Result mit `is_error=True`; und die umgebende ContextVar `server.request_context` ist entfernt. Eigene Methoden mit Vendor-Namespace sind über `add_request_handler(method, params_type, handler)` vollwertig unterstützt; das validiert eingehende Params gegen dein Modell, bevor dein Handler läuft. Und eine `middleware`-Liste (bewusst als vorläufig markiert) umhüllt jede eingehende Nachricht und ersetzt die privaten `_handle_*`-Methoden, die früher überschrieben wurden. -Unter der Haube wurde die `BaseSession`-Empfangsschleife aus v1 durch eine Dispatcher-Engine ersetzt, die Client und Server sich jetzt teilen, und sie macht mehrere Dinge auf dieser Seite gleichzeitig wahr: Ein einziges `Server`-Objekt bedient beide Protokollgenerationen, `Client(server)` dispatcht im Prozess ohne JSON-RPC-Framing, und ein Client-Request, der in den Timeout läuft, bricht jetzt tatsächlich den serverseitigen Handler ab. +Unter der Haube wurde die `BaseSession`-Empfangsschleife aus v1 durch eine Dispatcher-Engine ersetzt, die Client und Server sich jetzt teilen, und sie macht mehrere Dinge auf dieser Seite gleichzeitig wahr: Ein einziges `Server`-Objekt bedient beide Protokollgenerationen, `Client(server)` verteilt Requests direkt im Prozess, ohne JSON-RPC-Framing, und ein Client-Request, der in den Timeout läuft, bricht jetzt tatsächlich den serverseitigen Handler ab. Die Seite dazu ist **[Der Low-Level-Server](advanced/low-level-server.md)**; der **[Migrationsleitfaden](migration.md#lowlevel-server-decorator-based-handlers-replaced-with-constructor-on_-params)** geht jeden entfernten Hook durch. Wenn du nie unter `MCPServer` hinabgestiegen bist, betrifft dich nichts davon. @@ -171,11 +171,15 @@ Jeder vom Server ausgehende Request ist bei 2026-07-28 entfernt: Push-Elicitatio Der Ersatz dreht den Aufruf um. Ein Tool, das etwas von der Person am Host braucht, *gibt* die Frage *zurück* (`InputRequiredResult`), der Client beantwortet sie mit denselben Callbacks, die er schon immer hatte, und der Aufruf wird mit angehängten Antworten erneut versucht. `Client` treibt diese Schleife für dich. Auf dem Server baust du das Result selten selbst, weil eine **[Abhängigkeit](handlers/dependencies.md)** das übernimmt: Annotiere einen Parameter mit `Resolve(ask_quantity)`, wobei `ask_quantity` eine gewöhnliche Funktion ist, die du schreibst, und das SDK fragt über den Mechanismus, den die Verbindung unterstützt – ein Live-Elicitation-Request auf einer Legacy-Session oder ein Multi-Roundtrip bei 2026. Ein Tool-Body, beide Generationen: -```python title="dual_era.py" hl_lines="24 37-38" +```python title="server.py" hl_lines="21" --8<-- "docs_src/legacy_clients/tutorial001.py" ``` -Diese Datei ist das ganze Versprechen an einem Ort: ein Server, ein Tool mit `Resolve` dahinter, und ein Legacy-Client plus ein moderner Client, die beide ihre Antwort bekommen, im Speicher. **[Multi-Roundtrip-Requests](handlers/multi-round-trip.md)** erklärt den Mechanismus (einschließlich `request_state`, den das SDK für dich versiegelt und verifiziert); **[Elicitation](handlers/elicitation.md)** behandelt das Fragen. +```python title="client.py" hl_lines="14-15" +--8<-- "docs_src/legacy_clients/tutorial001_client.py" +``` + +Diese beiden Dateien sind das ganze Versprechen: ein Server, ein Tool mit `Resolve` dahinter, und ein Legacy-Client plus ein moderner Client, die beide ihre Antwort von demselben laufenden Server bekommen (**[Legacy-Clients unterstützen](run/legacy-clients.md)** geht sie durch). **[Multi-Roundtrip-Requests](handlers/multi-round-trip.md)** erklärt den Mechanismus (einschließlich `request_state`, den das SDK für dich versiegelt und verifiziert); **[Elicitation](handlers/elicitation.md)** behandelt das Fragen. !!! warning "Das ist die eine Stelle, an der ein portierter v1-Server sein Verhalten ändert" Deine eigenen Tests treffen es zuerst: `Client(mcp)` handelt gegen deinen v2-Server standardmäßig diff --git a/i18n/es/pages/advanced/apps.md b/i18n/es/pages/advanced/apps.md index 682404a1..163ceb90 100644 --- a/i18n/es/pages/advanced/apps.md +++ b/i18n/es/pages/advanced/apps.md @@ -1,6 +1,6 @@ --- translation: - sections: [0355618e5f4d5fe4, 1821eaf50f2d0b64, 82e0b28ebd3abf5a, 8ac39614c094f2d0, dab6ff945501ab2a, bd5565c3b2d4f959, 96819ce3d63a0487] + sections: [0355618e5f4d5fe4, 0fefa31fb7c7b585, 5c53e7487c9c70cc, 8ac39614c094f2d0, dab6ff945501ab2a, bd5565c3b2d4f959, 96819ce3d63a0487] tool: 1 --- # MCP Apps {#mcp-apps} @@ -25,7 +25,7 @@ y luego vuelve. ## Un reloj con cara visible {#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" ``` @@ -50,21 +50,42 @@ dentro de tu HTML. Te da `ontoolresult`, `callServerTool`, No todos los clientes muestran apps. La especificación es tajante sobre lo que eso significa para ti: -> Tools **MUST** return a meaningful `content` array even when UI is available. +> Las herramientas **DEBEN** devolver un array `content` significativo incluso cuando hay una UI disponible. El modelo lee `content`; el iframe es para humanos. Un host capaz de mostrar UI sigue entregando el resultado en texto al modelo, y un cliente solo de texto recibe *solo* eso. Así que el patrón canónico es una herramienta, dos respuestas. Mira `get_time` de nuevo: -```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)` es `True` solo cuando el cliente declaró la extensión `io.modelcontextprotocol/ui` **y** incluyó `text/html;profile=mcp-app` en su configuración `mimeTypes`. El campo es obligatorio, así que un cliente que lo omite -no cuenta. Eso es exactamente lo que declara `main()` en el mismo archivo: la -mitad cliente de la negociación, y vuelve la respuesta enriquecida. +no cuenta. Esta es la mitad cliente de la negociación: + +```python title="client.py" hl_lines="8 12" +--8<-- "docs_src/apps/tutorial001_client.py" +``` + +Sirve `server.py` por HTTP y luego ejecuta el cliente desde una segunda terminal: + +```console +uv run mcp run server.py --transport streamable-http +``` + +```console +python client.py +``` + +```text +2026-06-26T12:00:00Z +``` + +Volvió la respuesta enriquecida. Quita `extensions=[APPS_SUPPORT]` de la llamada a `Client` +y el mismo programa imprime `The time is 2026-06-26T12:00:00Z.` en su lugar, que es +todo lo que un cliente solo de texto llega a ver. !!! warning Nunca devuelvas un marcador de posición como `"[Rendered UI]"` como único contenido. Si el diff --git a/i18n/es/pages/advanced/extensions.md b/i18n/es/pages/advanced/extensions.md index 74a21630..095ed236 100644 --- a/i18n/es/pages/advanced/extensions.md +++ b/i18n/es/pages/advanced/extensions.md @@ -1,6 +1,6 @@ --- translation: - sections: [05891e7cc1938a13, b3c01a6af28c51ee, 7ffc91f5e38bdfe0, 717d3f235a8333a7, f471a13b2fe5d737, ed6af2df4b656dff] + sections: [05891e7cc1938a13, b3c01a6af28c51ee, 6eba6ce094f4417e, 125f28588c85dd7b, ddd8969b698ca11c, ed6af2df4b656dff] tool: 1 --- # Extensiones {#extensions} @@ -62,7 +62,7 @@ especificadas por el propio proyecto MCP. La extensión útil más pequeña es una herramienta y un mapa de ajustes: -```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" ``` @@ -74,18 +74,25 @@ La extensión útil más pequeña es una herramienta y un mapa de ajustes: * La extensión nunca recibe el servidor. Declara sus aportaciones como datos; `MCPServer` las consume. No hay un `self.server` que mutar. -Y `main()` es la prueba, un cliente en memoria directamente contra `mcp`: +Sírvela por HTTP, y un cliente es la prueba: -```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" +``` + +Cada `server.py` de esta página se sirve con ese comando, y cada `client.py` +se ejecuta a su lado con `python client.py` desde una segunda terminal. + ### Servir tus propios métodos {#serving-your-own-methods} Una extensión puede registrar **nuevos métodos de solicitud**: sus propios verbos, servidos junto a los de la especificación: -```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" ``` @@ -112,10 +119,10 @@ tiempo de ejecución: ### El lado del cliente {#the-client-side} -El `main()` del mismo archivo es toda la historia del cliente, sus dos mitades: +El cliente es su propio programa, y lleva las dos mitades de la historia del cliente: -```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)])` declara la extensión. Las @@ -127,6 +134,9 @@ El `main()` del mismo archivo es toda la historia del cliente, sus dos mitades: * Los métodos de proveedor bajan una capa hasta `client.session.send_request(...)`; `Client` solo incorpora métodos de primera clase para los verbos de la especificación. `send_request` acepta cualquier subclase de `Request`, así que la solicitud de proveedor pasa tal cual. +* `SearchRequest` y los dos modelos que lleva son el contrato de la extensión en el canal, + así que el cliente los declara por su cuenta. Una extensión publicada los distribuiría en + un paquete que ambos lados importan. ### Interceptar `tools/call` {#intercepting-toolscall} @@ -159,13 +169,20 @@ El hook envuelve `tools/call` y nada más. Para lo que afecta a cada mensaje, us ## Usar una extensión de cliente {#using-a-client-extension} Una **extensión de cliente** es el mismo contrato desde el lado que consume: un paquete de -comportamiento del lado del cliente detrás de un único identificador. Pasa las instancias a -`Client(extensions=[...])` y llama a las herramientas con normalidad: +comportamiento del lado del cliente detrás de un único identificador. Aquí el servidor responde a `buy` +con un recibo para canjear en lugar de la mercancía, y solo para un cliente que declaró la +extensión: -```python title="client.py" hl_lines="66-68" +```python title="server.py" hl_lines="22-25" --8<-- "docs_src/extensions/tutorial006.py" ``` +En el cliente, pasa las instancias a `Client(extensions=[...])` y llama a las herramientas con normalidad: + +```python title="client.py" hl_lines="33-35" +--8<-- "docs_src/extensions/tutorial006_client.py" +``` + `call_tool("buy", ...)` devuelve un `CallToolResult` normal, como cualquier otra llamada. Lo que cambió la extensión: el servidor ahora puede responder a `buy` con una **forma de resultado** `receipt` en lugar de un resultado final, y `Receipts` la termina (aquí canjeando el @@ -184,7 +201,7 @@ según la capacidad, el cliente no hace nada, como en el cliente de búsqueda de ```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")]) ``` ## Escribir una extensión de cliente {#writing-a-client-extension} @@ -192,8 +209,8 @@ client = Client(mcp, extensions=[advertise("com.example/search")]) Crea una subclase de `ClientExtension` y sobrescribe solo lo que necesites. Tres tipos de aportación, cada uno con un valor por defecto: `settings()`, `claims()` y `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" ``` * El identificador sigue la misma gramática que el del servidor y se valida cuando se @@ -231,14 +248,21 @@ forma reclamada que llega a un llamador del nivel de sesión lanza `UnexpectedCl Los métodos de solicitud propios de una extensión no necesitan registro del lado del cliente. Un tipo de solicitud de proveedor es una subclase de `mcp.types.Request` y pasa por `client.session.send_request`, -como en [Servir tus propios métodos](#serving-your-own-methods). Un añadido: cuando una -clave de los parámetros debe viajar en el header `Mcp-Name` (las especificaciones de extensión, como -tasks, lo exigen para sus verbos), el tipo de solicitud declara `name_param`: +como en [Servir tus propios métodos](#serving-your-own-methods). Toma un servidor cuya +extensión sirve un único verbo sobre un trabajo con nombre: -```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" ``` +Un añadido en el cliente: cuando una clave de los parámetros debe viajar en el header `Mcp-Name` +(las especificaciones de extensión, como tasks, lo exigen para sus verbos), el tipo de solicitud +declara `name_param`: + +```python title="client.py" hl_lines="20-23 28-29" +--8<-- "docs_src/extensions/tutorial007_client.py" +``` + La sesión replica `params["jobId"]` en `Mcp-Name` en cada ruta de envío, y un valor ausente falla de forma visible en lugar de omitir en silencio un header obligatorio. diff --git a/i18n/es/pages/advanced/low-level-server.md b/i18n/es/pages/advanced/low-level-server.md index a9fe35e2..71286b66 100644 --- a/i18n/es/pages/advanced/low-level-server.md +++ b/i18n/es/pages/advanced/low-level-server.md @@ -1,6 +1,6 @@ --- translation: - sections: [2c79b6338e09b7ac, 7edc43b3fae11314, 1086e77ce561cd7f, a3f71823df5efc31, 9fc7109f72201cae, d50fe7faead8cf68, 7bf25983df655b66, 6330e1f4c6029683, 2f1749c8c133fa1c, 8db7116fc8ddd0ee, ebc33704fbd74262, cd0e9c933350390e] + sections: [2c79b6338e09b7ac, 9d5d10a5f0405d0a, 1086e77ce561cd7f, a3f71823df5efc31, 9fc7109f72201cae, d50fe7faead8cf68, 7bf25983df655b66, 6330e1f4c6029683, 2f1749c8c133fa1c, 8db7116fc8ddd0ee, ebc33704fbd74262, 0fde3bcea081ba3a] tool: 1 --- # El Server de bajo nivel {#the-low-level-server} @@ -36,18 +36,22 @@ Cambiaron tres cosas, y son toda la API de bajo nivel: ### Pruébalo {#try-it} -Aquí no hay Inspector: `mcp dev` y `mcp run` solo aceptan un `MCPServer`. Al `Client` en memoria le da igual; acepta un `Server` de bajo nivel exactamente igual que acepta un `MCPServer`: +`mcp dev` y `mcp run` solo aceptan un `MCPServer`, así que este lo sirves por tu cuenta. La última línea de `server.py` construye una app ASGI común a partir de él, y uvicorn la ejecuta: -```python title="main.py" +```console +uvicorn server:app --port 8000 +``` + +Apunta el Inspector, o cualquier cliente, a `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) @@ -64,6 +68,8 @@ El mismo texto que produjo la versión con `@mcp.tool()`. Dos diferencias honest * `result.structured_content` es `None`. El servidor de alto nivel envuelve un `-> str` en `{"result": ...}` por ti; aquí nadie construye lo que no construiste. * `list_tools` devuelve el esquema que escribiste **tú**, carácter por carácter. La versión de alto nivel tenía `"title": "Query"` en cada propiedad y un `"title": "search_booksArguments"` en la raíz: artefactos de Pydantic. Aquí abajo, si se transmite, es porque lo pusiste ahí. +En una prueba te ahorras uvicorn y el puerto: `Client(server)` acepta un `Server` de bajo nivel en el mismo proceso exactamente igual que acepta un `MCPServer`, y **[Pruebas](../get-started/testing.md)** es ese patrón. + ## Nada se comprueba por ti {#nothing-is-checked-for-you} `MCPServer` rechaza un argumento incorrecto antes de que tu función llegue a ejecutarse, validando la llamada contra el esquema que generó (**[Herramientas](../servers/tools.md)**). @@ -215,4 +221,4 @@ Cada uno de estos es una idea para la que ya tienes el vocabulario; cada uno tie * `add_request_handler(method, params_type, handler)` sirve cualquier método. `initialize` está reservado. * Las capacidades que anuncia un `Server` se derivan de los handlers que registraste. -`Client(server)` trató a ambos servidores de forma idéntica porque *son* el mismo protocolo, que es justamente la idea. La siguiente capa hacia abajo no es una clase: es **[Middleware](middleware.md)**. +El cliente trató a ambos servidores de forma idéntica porque *son* el mismo protocolo, que es justamente la idea. La siguiente capa hacia abajo no es una clase: es **[Middleware](middleware.md)**. diff --git a/i18n/es/pages/advanced/pagination.md b/i18n/es/pages/advanced/pagination.md index d1abdfc0..49a007d5 100644 --- a/i18n/es/pages/advanced/pagination.md +++ b/i18n/es/pages/advanced/pagination.md @@ -1,6 +1,6 @@ --- translation: - sections: [a9aba7a026c7bd85, ed32bda7ba9ae33a, 7e64cc5646abb91f, 22a0129ee78b3c63, d875373c06d8d2f9] + sections: [a9aba7a026c7bd85, 83e2a08b9d46a398, 9fd8a0aa384b3257, 22a0129ee78b3c63, d875373c06d8d2f9] tool: 1 --- # Paginación {#pagination} @@ -31,9 +31,13 @@ La paginación es para el servidor cuya lista de recursos es en realidad una bas ### Pruébalo {#try-it} -`Client(server)` se conecta a un `Server` de bajo nivel en memoria exactamente igual que se conecta a un `MCPServer`. +`mcp run` solo acepta un `MCPServer`, así que este lo sirves tú mismo. La última línea de `server.py` construye una app ASGI normal a partir del `Server`, y uvicorn la ejecuta: -Llama a `list_resources()` sin argumentos. Obtienes diez recursos, de `book-1` a `book-10`, y `next_cursor` es la cadena `"10"`. +```console +uvicorn server:app --port 8000 +``` + +Apunta cualquier cliente (**[El cliente](../client/index.md)**, o el Inspector) a `http://localhost:8000/mcp` y llama a `list_resources()` sin argumentos. Obtienes diez recursos, de `book-1` a `book-10`, y `next_cursor` es la cadena `"10"`. Devuélvelo con `list_resources(cursor="10")` y el primer recurso es `book-11`; el nuevo `next_cursor` es `"20"`. @@ -43,7 +47,7 @@ La décima página vuelve con `next_cursor` en `None`. Listo. Cada método `list_*` de `Client` (`list_tools`, `list_resources`, `list_resource_templates`, `list_prompts`) acepta el argumento nombrado `cursor=`. Vaciar una lista paginada es un solo `while True`: -```python title="client.py" hl_lines="26-32" +```python title="client.py" hl_lines="9-15" --8<-- "docs_src/pagination/tutorial002.py" ``` @@ -51,7 +55,7 @@ Cada método `list_*` de `Client` (`list_tools`, `list_resources`, `list_resourc * Extiende **antes** de mirar `next_cursor`: la última página también tiene recursos. * `next_cursor is None` es la salida. Cualquier otra cosa vuelve directamente a `cursor=`, sin tocarla. -Ejecuta su `main()` e imprime `100 resources`: diez páginas de diez, unidas por un bucle que nunca supo que había diez páginas. +Con uvicorn todavía sirviendo `server.py`, ejecuta `python client.py` en una segunda terminal. Imprime `100 resources`: diez páginas de diez, unidas por un bucle que nunca supo que había diez páginas. Es el mismo bucle que **[El cliente](../client/index.md)** muestra para cada verbo `list_*`, y no cuesta nada frente a un servidor que no pagina: `next_cursor` es `None` en la primera respuesta y el bucle se ejecuta una vez. diff --git a/i18n/es/pages/client/caching.md b/i18n/es/pages/client/caching.md index e8d1613c..34174862 100644 --- a/i18n/es/pages/client/caching.md +++ b/i18n/es/pages/client/caching.md @@ -1,6 +1,6 @@ --- translation: - sections: [9e7b9a1710e5aeba, b74ca4c1d2ddddee, fa8714e61bf90c5a, 04db67a886b7271c, 857690fb8f876800] + sections: [9e7b9a1710e5aeba, 66a2e9acc9101d54, d3d25aa802f5145a, 04db67a886b7271c, 857690fb8f876800] tool: 1 --- # Sugerencias de caché {#caching-hints} @@ -31,7 +31,7 @@ Por defecto, cada resultado dice `ttlMs: 0, cacheScope: "private"`: caducado de En el `Server` de bajo nivel, los handlers construyen sus resultados a mano, y `ttl_ms` / `cache_scope` son simplemente campos de los modelos de resultado. Un handler que los define explícitamente siempre gana al mapa del constructor, campo por campo: -```python title="server.py" hl_lines="10 16" +```python title="server.py" hl_lines="11 17" --8<-- "docs_src/caching/tutorial002.py" ``` @@ -45,10 +45,24 @@ Una salvedad sobre las listas paginadas: el protocolo exige el **mismo `cacheSco En una sesión 2026-07-28, `Client` respeta las sugerencias por ti: tiene una caché de respuestas integrada, activada por defecto. Un resultado que llega con un `ttlMs` se almacena, y una llamada idéntica dentro de ese TTL se sirve desde la caché sin ida y vuelta. Un resultado que llega *sin* sugerencia no se guarda en caché: los resultados sin sugerencia reciben `CacheConfig.default_ttl_ms`, que es `0` por defecto (caducado de inmediato), así que un servidor que no declara nada ve exactamente el mismo tráfico llamada por llamada de siempre. -```python title="client.py" hl_lines="33 35 38" +Para verlo en acción, sirve el `server.py` de la sección anterior con uvicorn (su última línea construye la app ASGI). El handler imprime una línea cada vez que se ejecuta de verdad: + +```console +uvicorn server:app --port 8000 +``` + +```python title="client.py" hl_lines="20 23 28" --8<-- "docs_src/caching/tutorial003.py" ``` +Ejecuta `python client.py` desde una segunda terminal. Imprime las sugerencias que llevaba el primer resultado: el `ttlMs` del handler junto al `cacheScope` del mapa: + +```text +1000 public +``` + +La terminal del servidor cuenta el resto: entre los logs de solicitudes de uvicorn, `tools/list served` aparece tres veces. + Cuatro llamadas, tres consultas al servidor. La segunda llamada encontró una entrada vigente y nunca llegó al servidor; adelantar el reloj (inyectado) más allá del TTL hizo que la tercera volviera a consultar; la cuarta dijo `cache_mode="refresh"`. Ese argumento nombrado existe en los cinco verbos con caché (`list_tools`, `list_prompts`, `list_resources`, `list_resource_templates`, `read_resource`): * `"use"` (el valor por defecto) sirve una entrada vigente si la hay, y almacena lo consultado si no. @@ -57,7 +71,7 @@ Cuatro llamadas, tres consultas al servidor. La segunda llamada encontró una en Hay una regla por encima de `"use"`: **las llamadas que llevan `meta` siempre llegan al servidor.** Una solicitud con `meta` definido (un token de progreso, campos de trazado) espera una solicitud real por el canal, así que con `cache_mode="use"` se trata como `"refresh"`: se omite la lectura de la caché, y el resultado obtenido sigue reemplazando la entrada en caché. `"bypass"` y un `"refresh"` explícito se comportan como siempre. -Para desactivar la caché por completo, construye con `Client(server, cache=None)`: cada llamada vuelve a ser una ida y vuelta, y `cache_mode`, aunque se sigue aceptando, no hace nada. +Para desactivar la caché por completo, pasa `cache=None` al construir el `Client`: cada llamada vuelve a ser una ida y vuelta, y `cache_mode`, aunque se sigue aceptando, no hace nada. El alcance también se respeta automáticamente: las entradas `"private"` se asocian a la *partición* de la caché (más abajo), mientras que las `"public"` pueden optar por compartirse más ampliamente. Y **las notificaciones ganan al TTL** para las entradas exactas que nombran: una notificación `list_changed` desaloja el listado en caché correspondiente, y `resources/updated` desaloja la lectura en caché almacenada exactamente bajo su URI, por muy vigentes que estuvieran. En una conexión 2026-07-28 esas notificaciones llegan por un stream `subscriptions/listen` que abres con `client.listen(...)`, y el desalojo se completa antes de que tu observador vea el evento; **[Suscripciones](subscriptions.md)** es esa página. diff --git a/i18n/es/pages/client/callbacks.md b/i18n/es/pages/client/callbacks.md index 2369552e..a221b324 100644 --- a/i18n/es/pages/client/callbacks.md +++ b/i18n/es/pages/client/callbacks.md @@ -1,6 +1,6 @@ --- translation: - sections: [adf3c545b5be46b6, 916cd3ab1c03f461, e9be7a8d0eb0a456, 565890a636288ecf, 6af7e49db9129ec3, 06b0238c174186af, 90c6043be435fcb0] + sections: [adf3c545b5be46b6, 916cd3ab1c03f461, 32ef568335dd95a7, 565890a636288ecf, 6af7e49db9129ec3, 06b0238c174186af, 0abc5ea5cb7ff6b3] tool: 1 --- # Callbacks del cliente {#client-callbacks} @@ -63,7 +63,7 @@ Un `tools/call` tuyo, un `elicitation/create` de vuelta desde el servidor, respo `mode="legacy"` en la llamada a `Client(...)` hace trabajo real. Por defecto, `Client(...)` negocia la ruta moderna del protocolo, y esa ruta no tiene canal de retorno (back-channel) para las solicitudes del servidor al cliente: `ctx.elicit` falla antes de que tu callback llegue a ejecutarse. No lo decide el transporte; lo decide el protocolo - negociado, tanto en memoria como a través de una URL. Fija `mode="legacy"` siempre que tu cliente tenga + negociado. Fija `mode="legacy"` siempre que tu cliente tenga que responder a una; todas las pruebas detrás de esta página lo hacen. **[Versiones del protocolo](../protocol-versions.md)** tiene todos los detalles. En una sesión 2026-07-28 el callback no está muerto, se alimenta de otra forma: cuando una herramienta devuelve un @@ -151,4 +151,4 @@ Dos más. Ninguno declara nada. * `sampling_callback` y `list_roots_callback` funcionan igual pero atienden funcionalidades obsoletas; los servidores modernos usan solicitudes de varias idas y vueltas en su lugar. * `logging_callback` y `message_handler` reciben notificaciones. No declaran nada. -El primer argumento de `Client(...)` es un objeto de transporte. **[Transportes del cliente](transports.md)** cubre todos los tipos. +El primer argumento de `Client(...)` elige el transporte. **[Transportes del cliente](transports.md)** cubre todos los tipos. diff --git a/i18n/es/pages/client/index.md b/i18n/es/pages/client/index.md index f2bb09a5..a61c7104 100644 --- a/i18n/es/pages/client/index.md +++ b/i18n/es/pages/client/index.md @@ -1,6 +1,6 @@ --- translation: - sections: [ebef1e7a0df854f4, 8355cfaf1f76c9d5, 8e79141fc2985342, 46bdb07c7537e8a5, 80ce41579825a6fa, 5f0fa90494de8f65, 83d10514eaa62fa5, 9190555aa39a5d28, 84a4c9d8bf14dddb, 927d71cf40b58c30] + sections: [ebef1e7a0df854f4, 7e16449f66e7dfd6, eeb0682f7d2a1079, 5713f0196a34e6e7, 0e844597859e4248, 3a97d9195ddcc92e, 1da08c483e59c141, 84702cc6e0a1fd42, 8dee7a31c86ffc5c, 83a5bce168ef23d7] tool: 1 --- # El cliente {#the-client} @@ -11,13 +11,23 @@ Es un solo objeto con un solo ciclo de vida: lo construyes, entras en `async wit ## Tu primer cliente {#your-first-client} -```python title="client.py" hl_lines="14-18" +Un cliente necesita un servidor con el que hablar. Este Bookshop es al que se conectan todos los fragmentos de esta página. Guárdalo como `server.py` y déjalo ejecutándose por HTTP: + +```python title="server.py" --8<-- "docs_src/client/tutorial001.py" ``` -El servidor del principio solo está ahí para que tengas algo a lo que conectarte. El cliente son las cinco líneas resaltadas. +```console +uv run mcp run server.py --transport streamable-http +``` -* `Client(mcp)` recibe **el propio objeto servidor**. Ese es el transporte en memoria: sin subproceso, sin puerto, sin HTTP. Así se conectan todos los ejemplos de esta página y todas las pruebas que escribas. +Con eso queda disponible en `http://localhost:8000/mcp`. El cliente es un programa aparte. Guárdalo como `client.py` y ejecuta `python client.py` en una segunda terminal: + +```python title="client.py" hl_lines="7-11" +--8<-- "docs_src/client/tutorial001_client.py" +``` + +* `Client("http://localhost:8000/mcp")` recibe una **URL**, así que se conecta por Streamable HTTP al servidor que acabas de iniciar. * `async with` es el **ciclo de vida**. Al entrar se conecta y negocia; al salir se desconecta. No hay un par `connect()` / `close()`, y un `Client` no se puede reutilizar una vez que termina el bloque. * Dentro del bloque, los datos de la conexión ya están ahí como propiedades simples. @@ -25,10 +35,10 @@ El servidor del principio solo está ahí para que tengas algo a lo que conectar `Client` recibe un solo argumento posicional y resuelve el transporte a partir de su tipo: -* Una instancia de `MCPServer` (o del `Server` de bajo nivel): se conecta **en el mismo proceso**. -* Una cadena con una URL (`Client("http://localhost:8000/mcp")`): Streamable HTTP, el camino de producción. -* Un `StdioServerParameters`: el comando que se lanza como **subproceso**, con el que se habla a través de su stdin y su stdout. +* Una cadena con una URL (`Client("http://localhost:8000/mcp")`): Streamable HTTP, el transporte con el que despliegas. +* Un `StdioServerParameters`: el comando que se lanza como **subproceso** local, con el que se habla a través de su stdin y su stdout. * Un **transporte**: cualquier cosa que puedas usar con `async with ... as (read, write)`, como `streamable_http_client(url, http_client=...)` envolviendo tu propio cliente HTTP. +* Una instancia de `MCPServer` (o del `Server` de bajo nivel): se conecta **en el mismo proceso**, sin subproceso y sin puerto. Ese caso es para las pruebas, y **[Pruebas](../get-started/testing.md)** se construye sobre él. Todo lo demás en esta página es idéntico en los cuatro casos. Los encabezados, los subprocesos, los timeouts y el protocolo `Transport` tienen su propia página: **[Transportes del cliente](transports.md)**. @@ -49,11 +59,11 @@ Nunca elegiste una versión del protocolo. Por defecto, el `Client` sondea el se ## Listar herramientas {#listing-tools} -```python title="client.py" hl_lines="15-20" +```python title="client.py" hl_lines="8-13" --8<-- "docs_src/client/tutorial002.py" ``` -`list_tools()` devuelve un `ListToolsResult`; las herramientas están en `.tools`. Cada una es la definición completa que un host le entregaría a un modelo: +`list_tools()` devuelve un `ListToolsResult`; las herramientas están en `.tools`. Cada una es la definición completa que un host le entregaría a un modelo. Esta es la primera: ```python tool.name # 'search_books' @@ -77,6 +87,8 @@ y `tool.input_schema` es el JSON Schema que el servidor derivó de las anotacion Ese esquema es todo lo que una UI necesita para renderizar un formulario de argumentos, y todo lo que un modelo necesita para producir argumentos válidos. +La segunda herramienta, `lookup_book`, se registró sin `title=`, así que su `tool.title` es `None`. + !!! tip `title` es opcional, así que una UI que muestra herramientas a una persona tiene que elegir: el `title` si lo hay, el `name` si no. `from mcp.shared.metadata_utils import get_display_name` hace exactamente eso, @@ -86,7 +98,7 @@ Ese esquema es todo lo que una UI necesita para renderizar un formulario de argu `call_tool(name, arguments)` ejecuta la herramienta y te devuelve un `CallToolResult`. -```python title="client.py" hl_lines="27-34" +```python title="client.py" hl_lines="9-16" --8<-- "docs_src/client/tutorial003.py" ``` @@ -142,7 +154,7 @@ Una herramienta que lanza una excepción **no** la lanza en tu cliente. Vuelve c Los verbos de recursos vienen en pares: dos formas de listar, una de leer. -```python title="client.py" hl_lines="22-31" +```python title="client.py" hl_lines="9-18" --8<-- "docs_src/client/tutorial004.py" ``` @@ -156,7 +168,7 @@ A un cliente también se le puede avisar cuando cambia un recurso. En conexiones ## Prompts {#prompts} -```python title="client.py" hl_lines="15-20" +```python title="client.py" hl_lines="8-13" --8<-- "docs_src/client/tutorial005.py" ``` @@ -181,7 +193,7 @@ Un host le entrega esos mensajes directamente al modelo. Esa es toda la funciona Un servidor con un handler de autocompletado puede autocompletar argumentos de prompts y de plantillas de recursos mientras el usuario escribe. -```python title="client.py" hl_lines="27-31" +```python title="client.py" hl_lines="9-13" --8<-- "docs_src/client/tutorial006.py" ``` @@ -194,21 +206,21 @@ La respuesta está en `result.completion.values`. Escribe `"p"` y el servidor de Cada método `list_*` acepta un argumento nombrado `cursor=` y cada resultado trae un `next_cursor`. Cuando `next_cursor` es `None`, ya lo tienes todo. -```python title="client.py" hl_lines="22-30" +```python title="client.py" hl_lines="7-15" --8<-- "docs_src/client/tutorial007.py" ``` -Este bucle es correcto contra cualquier servidor. `MCPServer` devuelve todo en una sola página, así que `next_cursor` es `None` y el bucle se ejecuta una vez; por eso la mayoría del código nunca lo escribe. Los servidores que realmente paginan, y las reglas que siguen los cursores, están en **[Paginación](../advanced/pagination.md)**. +`list_all_tools` es correcta contra cualquier servidor. `MCPServer` devuelve todo en una sola página, así que `next_cursor` es `None` y el bucle se ejecuta una vez; por eso la mayoría del código nunca lo escribe. Los servidores que realmente paginan, y las reglas que siguen los cursores, están en **[Paginación](../advanced/pagination.md)**. ## En las pruebas {#in-tests} -`Client(mcp)`, sin proceso y sin puerto, ya es un banco de pruebas para tu servidor. +Cada `client.py` de esta página llegó a `server.py` por HTTP. En una prueba te saltas la red y le pasas a `Client` el propio objeto servidor: `from server import mcp` y luego `Client(mcp)`. Sin proceso, sin puerto, y todos los métodos anteriores funcionan igual. -Hay una opción del constructor pensada para eso: `Client(mcp, raise_exceptions=True)`. Solo tiene efecto en conexiones en memoria, y **[Pruebas](../get-started/testing.md)** es la página que la explica y construye todo el patrón a su alrededor. +Hay una opción del constructor pensada para eso: `Client(mcp, raise_exceptions=True)`. Solo tiene efecto en conexiones en el mismo proceso, y **[Pruebas](../get-started/testing.md)** es la página que la explica y construye todo el patrón a su alrededor. ## Resumen {#recap} -* `Client(x)` se conecta en memoria a un objeto servidor, por Streamable HTTP a una cadena con una URL, y por cualquier otra cosa mediante un transporte. +* `Client(x)` se conecta por Streamable HTTP a una cadena con una URL, lanza un subproceso para un `StdioServerParameters`, entra directamente en un transporte y, en las pruebas, recibe el propio objeto servidor. * `async with` es todo el ciclo de vida. Dentro, `server_capabilities` y `protocol_version` ya están rellenas; `server_info` e `instructions` también, cuando el servidor las proporciona. * `list_tools()` te da el `name`, `title`, `description` e `input_schema` de cada herramienta. * `call_tool()` devuelve `content` para el modelo, `structured_content` para tu código, e `is_error`. Una herramienta que lanza una excepción es un resultado, no una excepción. diff --git a/i18n/es/pages/client/oauth-clients.md b/i18n/es/pages/client/oauth-clients.md index 9bccb9f8..6451495d 100644 --- a/i18n/es/pages/client/oauth-clients.md +++ b/i18n/es/pages/client/oauth-clients.md @@ -1,6 +1,6 @@ --- translation: - sections: [c6899d3892bd9fa0, 79372cff3cc48a88, 63878d29e87c3e73, 13175843d3588af4, e7e2b9fd516f77de, 758f06399b513c1f, a05d7278487d610b] + sections: [c6899d3892bd9fa0, 79372cff3cc48a88, c2dae1ebe2ebd543, 13175843d3588af4, df06056fb16b3846, 758f06399b513c1f, a05d7278487d610b] tool: 1 --- # Clientes OAuth {#oauth-clients} @@ -81,18 +81,20 @@ Mira `main()`. El proveedor va en el **cliente httpx2**, el cliente httpx2 va en La primera vez que `Client` envía una solicitud, el servidor responde `401`. El proveedor toma el control: -1. **Descubrimiento.** Lee la cabecera `WWW-Authenticate`, obtiene los Protected Resource Metadata del servidor desde `/.well-known/oauth-protected-resource`, averigua qué servidor de autorización protege este recurso y obtiene los metadatos de *ese* servidor. +1. **Descubrimiento.** Lee la cabecera `WWW-Authenticate`, obtiene los Protected Resource Metadata del servidor desde `/.well-known/oauth-protected-resource`, averigua qué servidor de autorización protege este recurso y obtiene los metadatos de *ese* servidor. (A un servidor más antiguo que no publica metadatos del recurso se le piden, en su lugar, los metadatos del servidor de autorización en su propio origen.) En cualquier caso, los metadatos deben nombrar como `issuer` al servidor para el que se obtuvieron; cualquier otra cosa se rechaza. 2. **Registro.** ¿No hay nada en el almacenamiento? Te registra dinámicamente con tu `OAuthClientMetadata` y guarda el resultado. 3. **Autorización.** Genera el par PKCE y un `state`, construye la URL de autorización, espera tu `redirect_handler` y luego espera tu `callback_handler` para obtener el código. 4. **Intercambio.** Cambia el código por un `OAuthToken`, lo guarda y repite tu solicitud original con `Authorization: Bearer ...`. Después de eso, se queda callado. Los tokens salen del almacenamiento, un token de acceso caducado se renueva con el token de actualización y solo cuando nada de eso funciona vuelve a ejecutar el flujo. +Una regla de transporte se aplica a todas estas solicitudes: igual que la solicitud MCP dentro de la que se ejecutan, siguen una redirección solo cuando permanece en el mismo origen y conserva el método (un 307/308 por una barra final, por ejemplo), y tratan cualquier otra redirección como si esa URL no respondiera. + No escribiste nada de eso. Quedan dos argumentos nombrados (`client_metadata_url` y `validate_resource_url`), y este archivo no necesita ninguno. `client_metadata_url` es el que vale la pena conocer; tiene su propia sección más abajo. ### Pruébalo {#try-it} -La mayoría de los ejemplos de esta documentación puedes comprobarlos con un `Client(server)` en memoria. Este no: todo el sentido del flujo es un `401` HTTP, y no hay HTTP entre un cliente en memoria y su servidor. +El `Client(server)` en memoria que usan tus pruebas no sirve aquí: todo el sentido del flujo es un `401` HTTP, y no hay HTTP entre un cliente en memoria y su servidor. El repositorio incluye la versión real. `examples/servers/simple-auth/` ejecuta un servidor de autorización independiente y un servidor MCP protegido; `examples/clients/simple-auth-client/` es el cliente de esta página convertido en una pequeña CLI. Su README tiene los dos comandos: inicia los servidores, ejecuta el cliente contra ellos y verás pasar los cuatro pasos. @@ -110,13 +112,14 @@ Un trabajo nocturno, un paso de CI, otro servicio. No hay navegador ni nadie que `ClientCredentialsOAuthProvider` es el mismo `httpx2.Auth`, sin el humano: -```python title="client.py" hl_lines="4 27-33" +```python title="client.py" hl_lines="4 27-34" --8<-- "docs_src/oauth_clients/tutorial002.py" ``` Qué cambió: * Sin `OAuthClientMetadata`, sin handlers. Pasas `client_id` y `client_secret`; el proveedor construye un registro `client_credentials` mínimo en torno a ellos y se salta el registro dinámico por completo. +* `issuer` nombra el servidor de autorización que emitió esas credenciales; usa el valor `issuer` que devuelve su documento `/.well-known/oauth-authorization-server`. El descubrimiento sigue ejecutándose como arriba, pero las solicitudes de token solo se construyen a partir de los metadatos de *ese* emisor; si el servidor MCP apunta a cualquier otro sitio, el flujo se detiene con un `OAuthFlowError`. Omitirlo está obsoleto y pasa a ser obligatorio en 3.0 (consulta **[Funcionalidades obsoletas](../deprecated.md#deprecated-sdk-helpers)**); hasta entonces, el proveedor emite un aviso y usa el servidor de autorización que encuentre el descubrimiento. * `scope` es una cadena separada por espacios, el formato que OAuth usa en lo que se transmite. * Todo lo que viene después es idéntico: el mismo `TokenStorage`, el mismo `httpx2.AsyncClient(auth=...)`, el mismo `streamable_http_client`. @@ -129,7 +132,7 @@ Por defecto, el secreto viaja como autenticación HTTP Basic en la solicitud de Hay un proveedor más en `mcp.client.auth.extensions.client_credentials`: **`PrivateKeyJWTOAuthProvider`**, para clientes que se autentican con un JWT en lugar de un secreto compartido (`private_key_jwt`, la variante de par de claves e identidad de carga de trabajo). Sigue - el mismo patrón: construye uno y ponlo en `auth=`. El mismo módulo incluye + el mismo patrón: construye uno (acepta el mismo `issuer` opcional) y ponlo en `auth=`. El mismo módulo incluye `SignedJWTParameters` y `static_assertion_provider`, dos utilidades que construyen su aserción. Hay una situación más sin humanos: el cliente pertenece a una empresa cuyo proveedor de identidad, y no el usuario, decide a qué servidores MCP puede acceder. Ese es un grant distinto, con su propio modelo de confianza y su propia página, **[Aserción de identidad](identity-assertion.md)**. diff --git a/i18n/es/pages/client/session-groups.md b/i18n/es/pages/client/session-groups.md index 438e68c1..164f49f7 100644 --- a/i18n/es/pages/client/session-groups.md +++ b/i18n/es/pages/client/session-groups.md @@ -1,6 +1,6 @@ --- translation: - sections: [09c857a25a9dc37a, 43bc6a76a243a50e, 0a716022a88768df, 4b7f78042bfcfff7, c112662e61b03315, 58974ba1f489a8b4, d18adbdbb835ea73] + sections: [09c857a25a9dc37a, 43bc6a76a243a50e, 0a716022a88768df, 4b7f78042bfcfff7, c112662e61b03315, 58974ba1f489a8b4, ed4d17e894864056] tool: 1 --- # Grupos de sesiones {#session-groups} @@ -78,7 +78,7 @@ Si ya tienes una `ClientSession` conectada (`Client.session` lo es), pásala a ` ## Resumen {#recap} * `ClientSessionGroup` mantiene muchas conexiones a servidores y reúne sus herramientas, recursos y prompts en un `dict` para cada tipo. -* `connect_to_server(params)` por servidor. Recibe parámetros de transporte, nunca el objeto servidor ni la URL que recibe un `Client`. +* `connect_to_server(params)` por servidor. Recibe parámetros de transporte, nunca la URL ni el `Transport` que recibe un `Client`. * `group.call_tool(name, arguments)` enruta por ti al servidor al que pertenece. * Los nombres deben ser únicos en todo el grupo; dos servidores con una herramienta `search` no pueden coexistir por sí solos. * `component_name_hook=` reescribe cada nombre registrado. La clave del dict cambia; el nombre que se transmite por el canal, no. diff --git a/i18n/es/pages/client/transports.md b/i18n/es/pages/client/transports.md index 4f25dc11..84f91ff2 100644 --- a/i18n/es/pages/client/transports.md +++ b/i18n/es/pages/client/transports.md @@ -1,6 +1,6 @@ --- translation: - sections: [9cac816674181eb0, 0700f337babcd4dd, 2bde0dd58cdf00f5, 40b4916d82eaf1d4, 3d0832f39b0d7059, dfa4446556badef0, 5bd93be2ab2ecb9c] + sections: [9cac816674181eb0, 7c157764133fea1f, 40b4916d82eaf1d4, 10d151f2cc75317f, 3d0832f39b0d7059, 92742ba36533633d, 0aeca6145e7bd302] tool: 1 --- # Transportes del cliente {#client-transports} @@ -11,30 +11,15 @@ Nunca configuras uno por separado. `Client` recibe un único argumento posiciona El lado del *servidor* de cada uno (lo que hace `mcp.run()` y lo que despliegas) está en **[Ejecutar el servidor](../run/index.md)**. -## En memoria {#in-memory} - -Pasa el propio objeto del servidor: - -```python title="client.py" hl_lines="14" ---8<-- "docs_src/client_transports/tutorial001.py" -``` - -Sin subproceso, sin puerto, sin bytes por ningún canal. El cliente y el servidor son dos objetos en el mismo proceso, y aun así la llamada pasa por la capa real del protocolo: `search_books` se lista, se valida y se invoca exactamente igual que por HTTP. - -Eso lo convierte en dos cosas a la vez: - -* **Un arnés de pruebas.** Todos los ejemplos de esta documentación se ejercitan así, y la página **[Pruebas](../get-started/testing.md)** construye todo el patrón en torno a ello. -* **Una API de integración.** Una aplicación que construye el servidor no necesita un salto de red para llamar a sus herramientas. - ## Streamable HTTP {#streamable-http} -Pasa una cadena con una URL y obtienes **Streamable HTTP**, el transporte con el que despliegas: +Pasa una cadena con una URL y obtienes **Streamable HTTP**, el transporte detrás del que despliegas y el primero al que deberías recurrir: ```python title="client.py" hl_lines="5" --8<-- "docs_src/client_transports/tutorial002.py" ``` -Ese es todo el cliente de producción. `Client` envuelve la URL en `streamable_http_client(...)` por ti, encima de un `httpx2.AsyncClient` configurado como MCP necesita: `follow_redirects=True`, un timeout de 30 segundos para connect/write/pool y un timeout de lectura de 300 segundos, porque el servidor puede mantener abierto un flujo de respuesta. +Ese es todo el cliente de producción. `Client` envuelve la URL en `streamable_http_client(...)` por ti, encima de un `httpx2.AsyncClient` configurado como MCP necesita: un timeout de 30 segundos para connect/write/pool y un timeout de lectura de 300 segundos, porque el servidor puede mantener abierto un flujo de respuesta. !!! check Un `Client` que has construido **no** está conectado. La construcción solo elige el transporte; @@ -50,7 +35,7 @@ Ese es todo el cliente de producción. `Client` envuelve la URL en `streamable_h En cuanto necesites un encabezado `Authorization`, una cookie, un proxy, mTLS o un timeout distinto, construye tú mismo el `httpx2.AsyncClient` y entrégaselo a `streamable_http_client`: -```python title="client.py" hl_lines="8-14" +```python title="client.py" hl_lines="8-13" --8<-- "docs_src/client_transports/tutorial003.py" ``` @@ -80,9 +65,30 @@ estándar `SSL_CERT_FILE`/`SSL_CERT_DIR` o pasa un `verify=ssl_context` explíci !!! info `httpx2` conserva la API conocida de `httpx`, así que si conoces `httpx` ya sabes cómo hacer la autenticación, los proxies, los event hooks, los reintentos y los límites de conexión aquí. El SDK no añade nada encima ni quita - nada. También es donde se conecta OAuth: + nada, salvo el [manejo de redirecciones](#redirects). También es donde se conecta OAuth: `httpx2.AsyncClient(auth=OAuthClientProvider(...))`. Todo ese flujo está en **[Clientes OAuth](oauth-clients.md)**. +### Redirecciones {#redirects} + +El transporte se conecta a la URL que le diste, y solo a ese origen. + +* Una redirección `307`/`308` que se queda en el mismo esquema, host y puerto se sigue, y también `http://` → `https://` en el mismo host. Eso cubre la redirección habitual de barra final `/mcp` → `/mcp/`. +* Una redirección a cualquier otro sitio **no** se sigue. La llamada falla con: + + ```text + MCPError: Redirect to https://other.example.com/mcp not followed; use that URL as the endpoint if it is the intended server + ``` + + Si esa URL es el servidor que querías, ponla en tu configuración. Si no, el servidor o un proxy delante de él está mal configurado. + +Esto vale para cualquier `httpx2.AsyncClient` que pases: su ajuste `follow_redirects` no se consulta para las solicitudes MCP, en ningún sentido. Los proveedores OAuth del SDK aplican la misma regla a sus propias solicitudes. + +!!! tip + `Redirect to http://… not followed: it would downgrade this HTTPS endpoint to plain HTTP` significa que el + servidor está detrás de un proxy que termina TLS del que no sabe nada y está emitiendo redirecciones `http://`. + Eso se arregla en el servidor (**[Desplegar y escalar](../run/deploy.md#behind-a-tls-terminating-proxy)**), + o usando la URL `https://…/` exacta que sugiere el mensaje. + ## stdio {#stdio} Un servidor **stdio** es un subproceso. El cliente lo lanza, escribe JSON-RPC en su stdin y lee JSON-RPC de su stdout. Así es como un host de escritorio ejecuta un servidor en tu máquina: un host *es* este código más una interfaz de usuario, y **[Conectar a un host real](../get-started/real-host.md)** es la misma relación vista desde el lado del host, como archivo de configuración. @@ -105,6 +111,18 @@ El stderr del proceso hijo va al tuyo. Para enviarlo a otro sitio, construye tú Un servidor que necesita una clave de API no la encontrará ahí. Pásala explícitamente con `env=`; esas variables se fusionan encima de la lista de permitidos. Eso es lo que hace `BOOKSHOP_API_KEY` arriba. +## En memoria {#in-memory} + +En una prueba no hay nada que desplegar ni nada que lanzar. Pasa el propio objeto del servidor: + +```python hl_lines="14" +--8<-- "docs_src/client_transports/tutorial001.py" +``` + +Sin subproceso, sin puerto, sin bytes por ningún canal. El cliente y el servidor son dos objetos en el mismo proceso, y aun así la llamada pasa por la capa real del protocolo: `search_books` se lista, se valida y se invoca exactamente igual que por HTTP. **[Pruebas](../get-started/testing.md)** construye todo el patrón en torno a ello. + +La misma forma sirve además como API de integración: una aplicación que construye ella misma el servidor puede llamar a sus herramientas sin un salto de red. + ## SSE {#sse} `sse_client(url)`, de `mcp.client.sse`, es el transporte HTTP al que reemplazó Streamable HTTP. Envuélvelo igual, `Client(sse_client("http://localhost:8000/sse"))`, para hablar con un servidor que todavía lo usa, y no construyas nada nuevo sobre él. @@ -113,15 +131,16 @@ El stderr del proceso hijo va al tuyo. Para enviarlo a otro sitio, construye tú Para `Client`, todo lo anterior es lo mismo. -Un **transporte** es cualquier gestor de contexto asíncrono que produce un par `(read, write)` de flujos de mensajes: formalmente, el protocolo `Transport` de `mcp.client`. `Client` resuelve su argumento por tipo: un objeto de servidor se conecta dentro del proceso, un `str` se convierte en `streamable_http_client(url)`, un `StdioServerParameters` se convierte en `stdio_client(params)` y cualquier otra cosa se entra directamente como transporte. Esa última regla es la razón por la que `stdio_client(...)`, `streamable_http_client(...)` y `sse_client(...)` encajan todos en el mismo hueco, y por la que puedes escribir el tuyo. +Un **transporte** es cualquier gestor de contexto asíncrono que produce un par `(read, write)` de flujos de mensajes: formalmente, el protocolo `Transport` de `mcp.client`. `Client` resuelve su argumento por tipo: un `str` se convierte en `streamable_http_client(url)`, un `StdioServerParameters` se convierte en `stdio_client(params)`, un objeto de servidor se conecta dentro del proceso y cualquier otra cosa se entra directamente como transporte. Esa última regla es la razón por la que `stdio_client(...)`, `streamable_http_client(...)` y `sse_client(...)` encajan todos en el mismo hueco, y por la que puedes escribir el tuyo. ## Resumen {#recap} -* `Client(mcp)` (el objeto del servidor) se conecta en memoria. Úsalo para pruebas y para integración. * `Client("http://.../mcp")` (una URL) se conecta por Streamable HTTP, el transporte de producción. * Los encabezados, la autenticación, los proxies y los timeouts van en un `httpx2.AsyncClient` que pasas a `streamable_http_client(url, http_client=...)`. No existe el argumento nombrado `headers=`. +* Las redirecciones se siguen solo dentro del propio origen de la URL (un `307`/`308` de barra final), más `http`→`https` en el mismo host. Cualquier otra cosa falla con `Redirect to … not followed`; configura la URL final. * stdio es `Client(StdioServerParameters(...))`. Envuélvelo tú mismo en `stdio_client(...)` solo para redirigir el stderr del proceso hijo. * El subproceso recibe un entorno con lista de permitidos, no el tuyo; `env=` se añade a él. +* `Client(mcp)` (el objeto del servidor) se conecta en memoria. Úsalo en pruebas, o para integrar un servidor en la aplicación que lo construyó. * Un transporte es cualquier cosa con la que puedas hacer `async with x as (read, write)`. `Client` entrega directamente a ese protocolo todo lo que no sea un objeto de servidor, una URL ni un `StdioServerParameters`. * Construir un `Client` elige el transporte. `async with` lo abre. diff --git a/i18n/es/pages/deprecated.md b/i18n/es/pages/deprecated.md index 43beb87a..9e93dfe7 100644 --- a/i18n/es/pages/deprecated.md +++ b/i18n/es/pages/deprecated.md @@ -1,11 +1,11 @@ --- translation: - sections: [490237e61c3a7a44, 01262a123ad9501d, 429db5b574a2ac08, e2d0d273fbd2d74b, 64ab0331e868f3d4, 6c8878ce2d1f6d56, 4068f23e371bf0b3, eaef75b8725bc931] + sections: [4c1dea378b2b1bf7, 01262a123ad9501d, 429db5b574a2ac08, e2d0d273fbd2d74b, 64ab0331e868f3d4, 6c8878ce2d1f6d56, c3d1099701156881, e185a1b8e53669f6] tool: 1 --- # Funcionalidades obsoletas {#deprecated-features} -La especificación 2026-07-28 retira cinco cosas. El SDK sigue implementando todas y cada una, y todas llevan ahora un **aviso de obsolescencia**. Una función auxiliar del SDK queda obsoleta por su cuenta y aparece [al final](#deprecated-sdk-helpers). +La especificación 2026-07-28 retira cinco cosas. El SDK sigue implementando todas y cada una, y todas llevan ahora un **aviso de obsolescencia**. Algunas partes del SDK quedan obsoletas por su cuenta y aparecen [al final](#deprecated-sdk-helpers). La tabla siguiente nombra cada funcionalidad obsoleta, explica por qué desaparece e indica el reemplazo sobre el que construir. @@ -137,11 +137,13 @@ Esa es toda la API. No hay un interruptor por método, y tampoco lo quieres: la ## Funciones auxiliares del SDK obsoletas {#deprecated-sdk-helpers} -No son cambios de la especificación, solo detalles internos del SDK con un reemplazo mejor. Avisan con el mismo `MCPDeprecationWarning` y se eliminarán en 3.0. +No son cambios de la especificación, solo usos del SDK que tienen un reemplazo mejor. Avisan con el mismo `MCPDeprecationWarning`, y 3.0 elimina la forma antigua. | Obsoleto | Qué hacer en su lugar | |---|---| | `FuncMetadata.call_fn_with_arg_validation()` | `FuncMetadata.validate_arguments()` y después `FuncMetadata.call_fn()`. Solo lo llamaba el código que maneja `FuncMetadata` directamente (una subclase propia de `Tool`, por ejemplo). | +| `AuthSettings(resource_server_url=...)` sin `validate_token_resource=` | Defínelo: `True` hace que el servidor rechace los tokens bearer que tu verificador no declara como emitidos para `resource_server_url`; `False` indica que tu verificador comprueba por sí mismo la audiencia del token (consulta **[Autorización](run/authorization.md#a-token-verifier)**). Sin definir se comporta como `False`; 3.0 hace que `True` sea el valor por defecto siempre que `resource_server_url` esté definido. | +| `ClientCredentialsOAuthProvider(...)` o `PrivateKeyJWTOAuthProvider(...)` sin `issuer=` | Pasa `issuer=` con el servidor de autorización que emitió las credenciales (consulta **[Escribir clientes OAuth](client/oauth-clients.md#machine-to-machine)**). Sin él, el servidor MCP decide qué servidor de autorización las recibe; 3.0 hace obligatorio el argumento nombrado. | ## Resumen {#recap} @@ -150,7 +152,7 @@ No son cambios de la especificación, solo detalles internos del SDK con un reem * Obsoleto es solo un aviso: no hay cambios en lo que se transmite, todo sigue funcionando contra sesiones anteriores a 2026 y recibes un `MCPDeprecationWarning` visible (un `UserWarning`, así que está activo por defecto). * El muestreo y los roots necesitan además un canal de retorno que una sesión 2026-07-28 no tiene. En una conexión moderna avisan y después lanzan una excepción. * `warnings.filterwarnings("ignore", category=MCPDeprecationWarning)` silencia toda la categoría; `"error::mcp.MCPDeprecationWarning"` en pytest la convierte en un fallo de prueba. -* Una función auxiliar del SDK, `FuncMetadata.call_fn_with_arg_validation()`, queda obsoleta por separado y se eliminará en 3.0. +* Las [partes del SDK obsoletas](#deprecated-sdk-helpers) siguen la misma regla: avisan ahora, y 3.0 elimina la forma antigua. * El código nuevo no debería construirse sobre nada de esto. Todas las demás páginas de esta documentación enseñan la API actual. diff --git a/i18n/es/pages/get-started/first-steps.md b/i18n/es/pages/get-started/first-steps.md index 86fce406..a0f3b3e8 100644 --- a/i18n/es/pages/get-started/first-steps.md +++ b/i18n/es/pages/get-started/first-steps.md @@ -1,6 +1,6 @@ --- translation: - sections: [0d6c05bcbf836bf3, 59a7b14eeefc68c1, 7114d8d6daba203f, e8bbb56a98ba7bc9, 5138010f6159901c, f78da7c7c363d4c6, 220a939cab348686] + sections: [0d6c05bcbf836bf3, 9a78b5f6b44b18ab, 7114d8d6daba203f, e8bbb56a98ba7bc9, bfd2fd1153e71dac, 1615a994ef071fdd, 65c599fae991f245] tool: 1 --- # Primeros pasos {#first-steps} @@ -17,7 +17,7 @@ Tres palabras que verás en cada página a partir de aquí: * Un **cliente** vive dentro del host y habla MCP. El host ejecuta un cliente por cada servidor al que está conectado. * Un **servidor** es lo que construyes con este SDK. Expone cosas a los clientes. Nunca habla directamente con el modelo. -Tú escribes el servidor. Los hosts son el producto de otra persona. El SDK también te da un `Client`. Lo usarás para probar tus servidores, y aparece más adelante en esta página. +Tú escribes el servidor. Los hosts son el producto de otra persona. El SDK también te da un `Client`, la misma clase que usaría un host para llegar a un servidor por URL o lanzarlo como subproceso. Aparece más adelante en esta página, y es también la forma en que probarás tus servidores. ## Las tres primitivas {#the-three-primitives} @@ -83,22 +83,20 @@ Viste tres pestañas en el Inspector. ¿Cómo supo que había tres? Cuando un cliente se conecta, el servidor declara sus **capacidades**: qué familias de solicitudes va a responder. El cliente usa esa declaración para decidir qué vale la pena pedir siquiera. Nunca la escribiste; `MCPServer` la declara por ti. -Míralo tú mismo. El `Client` del SDK acepta el objeto servidor directamente y se conecta a él **en memoria** (sin subproceso, sin puerto): +Míralo tú mismo. Deja `server.py` ejecutándose sobre HTTP en una terminal: -```python -import asyncio +```console +uv run mcp run server.py --transport streamable-http +``` -from mcp import Client +y apunta un cliente hacia él desde otra: -from server import mcp +```python title="client.py" hl_lines="7-8" +--8<-- "docs_src/first_steps/tutorial001_client.py" +``` - -async def main() -> None: - async with Client(mcp) as client: - print(client.server_capabilities.model_dump(exclude_none=True)) - - -asyncio.run(main()) +```console +python client.py ``` ```text @@ -118,17 +116,18 @@ Ese diccionario son las **capacidades** declaradas de tu servidor. Es lo primero Fíjate en lo que no está. `completions` (autocompletado de argumentos para plantillas de recurso y prompts) necesita un handler que escribes tú, este servidor no tiene uno, así que la capacidad está ausente y un cliente bien hecho no la pedirá. Esa es la regla para todo lo opcional: registra la cosa y la capacidad aparece; **[Autocompletado](../servers/completions.md)** lo demuestra. !!! info - `Client(mcp)` es el mismo cliente en memoria con el que se prueba cada ejemplo de esta - documentación, y es como probarás los tuyos. Tiene una página entera: **[Pruebas](testing.md)**. + Ese `client.py` es un cliente MCP completo, y **[El cliente](../client/index.md)** es su página. + En una prueba te saltas la terminal y el puerto y le pasas a `Client` el propio objeto servidor, + `Client(mcp)`. Eso también tiene una página entera: **[Pruebas](testing.md)**. ## Lo que no escribiste {#what-you-did-not-write} Repasa esta página. Escribiste tres pequeñas funciones de Python. **No** escribiste: * Un JSON Schema. `a: int, b: int` *es* el esquema de `add`. -* Un handler de solicitudes. `tools/list`, `resources/read`, `prompts/get`: todos servidos por ti. +* Un handler de solicitudes. `tools/list`, `resources/read`, `prompts/get`: el SDK los atiende todos por ti. * Una declaración de capacidades. `MCPServer` la hizo por ti. -* Una línea de protocolo. La negociación de versión, el encuadre JSON-RPC, el intercambio de capacidades: todo ocurrió dentro de `mcp dev` y `Client(mcp)`, y nunca lo viste. +* Una línea de protocolo. La negociación de versión, el encuadre JSON-RPC, el intercambio de capacidades: todo ocurrió dentro de `mcp dev` y `client.py`, y nunca lo viste. Esa proporción es la razón de ser del SDK. @@ -139,6 +138,6 @@ Esa proporción es la razón de ser del SDK. * Un decorador por primitiva: `@mcp.tool()`, `@mcp.resource(uri)`, `@mcp.prompt()`. Nombre, descripción y esquema salen de la función. * Una URI con un `{param}` crea una **plantilla** de recurso, que se lista aparte de los recursos concretos. * Las **capacidades** del servidor se declaran por ti, y un cliente solo pide lo que un servidor declara. -* `Client(mcp)` se conecta al objeto servidor en memoria: tu entorno de pruebas desde el primer día. +* `Client("http://localhost:8000/mcp")` habla con tu servidor en ejecución. Pásale en su lugar el objeto servidor, `Client(mcp)`, y es tu entorno de pruebas desde el primer día. Lo siguiente es **[Conectar a un host real](real-host.md)**: este servidor dentro de Claude Desktop o un IDE, de verdad. Después, **[Pruebas](testing.md)**: una página, un cliente en memoria, y nunca más adivinas si funciona. Tras eso, cada primitiva tiene su propia página, empezando por la que maneja el modelo: **[Herramientas](../servers/tools.md)**. diff --git a/i18n/es/pages/get-started/testing.md b/i18n/es/pages/get-started/testing.md index 2c11eaac..4d67310c 100644 --- a/i18n/es/pages/get-started/testing.md +++ b/i18n/es/pages/get-started/testing.md @@ -1,13 +1,13 @@ --- translation: - sections: ['4926721070127497', c52a1de2b6b32f40, 8e792bf8c7489ec6, 627195f7159e24ef] + sections: [5d13c2f0ba42c0d2, c52a1de2b6b32f40, 8e792bf8c7489ec6, 38552ea228b0a04f] tool: 1 --- # Pruebas {#testing} -El SDK de Python incluye una clase `Client` con un **transporte en memoria**: le pasas tu objeto servidor y se conecta a él directamente. +La clase `Client` del SDK, la misma que se conecta a una URL o lanza un subproceso, también se conecta **en memoria**: le pasas tu objeto servidor y habla con él directamente. -Sin subproceso. Sin puerto. Sin transporte alguno. Es la misma idea que el `TestClient` de FastAPI. +Sin subproceso. Sin puerto. Nada que se transmita por ningún canal. Es la misma idea que el `TestClient` de FastAPI. ## Uso básico {#basic-usage} @@ -97,11 +97,11 @@ remoto. En una prueba eso es exactamente lo que *no* quieres, y es lo que cambia Déjalo activado en las pruebas. No tiene ningún sentido en código de producción. -## En proceso por defecto {#in-process-by-default} +## Neutral respecto a la generación por defecto {#era-neutral-by-default} !!! note `Client(mcp)` se conecta en proceso y es **neutral respecto a la generación** por defecto: sondea - el servidor y elige la ruta de protocolo adecuada. Fija `mode="legacy"` si tu prueba ejercita + el servidor y elige la ruta de protocolo adecuada. Fija `mode="legacy"` si tu prueba comprueba comportamientos específicos de las conexiones heredadas (envío de muestreo (sampling) o elicitación (elicitation), `message_handler`), y quita `raise_exceptions=True` en ese caso: una conexión heredada nunca depura los errores en primer lugar, y el indicador relanza el fallo diff --git a/i18n/es/pages/handlers/multi-round-trip.md b/i18n/es/pages/handlers/multi-round-trip.md index 75555cb4..4c0e10c6 100644 --- a/i18n/es/pages/handlers/multi-round-trip.md +++ b/i18n/es/pages/handlers/multi-round-trip.md @@ -1,6 +1,6 @@ --- translation: - sections: [74011e683045eea9, 9b64cc175c18b6a9, 4b41be4824030397, e3b1502da786ec33, 71e41161f143c6a9, 9ec2c1eeb8c36378, 8dd027377d46448b, f81491125dcbfe8b] + sections: [74011e683045eea9, 9b64cc175c18b6a9, 4b41be4824030397, e3b1502da786ec33, 71e41161f143c6a9, 9ec2c1eeb8c36378, b47667184ca5b516, f81491125dcbfe8b] tool: 1 --- # Solicitudes de varias idas y vueltas {#multi-round-trip-requests} @@ -164,7 +164,7 @@ El `Server` de bajo nivel es el nivel sin pilas incluidas: a diferencia de `MCPS ## Un resultado de 2026-07-28 {#a-2026-07-28-result} -`InputRequiredResult` solo existe en la versión del protocolo **2026-07-28**. El `Client(server)` en memoria la negocia por ti; a través del canal, `mode="auto"` la descubre. Tras conectar, `client.protocol_version` te dice qué obtuviste. +`InputRequiredResult` solo existe en la versión del protocolo **2026-07-28**. El `mode="auto"` por defecto de `Client` la descubre en cualquier conexión. Tras conectar, `client.protocol_version` te dice qué obtuviste. !!! warning Una sesión anterior a 2026 no tiene dónde poner un `InputRequiredResult`. Devuelve uno desde tu diff --git a/i18n/es/pages/handlers/progress.md b/i18n/es/pages/handlers/progress.md index 8f454ded..6e84b938 100644 --- a/i18n/es/pages/handlers/progress.md +++ b/i18n/es/pages/handlers/progress.md @@ -1,6 +1,6 @@ --- translation: - sections: [5315262fe26b33e1, 9d8e98840f1b78f0, 0284b215e85366c4, 8534d8dbb4053a70, 2966fac6fe697007] + sections: [5315262fe26b33e1, 9d8e98840f1b78f0, 52d6009a07e770ea, 8534d8dbb4053a70, 2966fac6fe697007] tool: 1 --- # Progreso {#progress} @@ -29,19 +29,17 @@ Tres argumentos, y tú decides qué significan: El cliente lo activa **por llamada**, pasando `progress_callback=` a `call_tool`: -```python title="client.py" hl_lines="7 16" +```python title="client.py" hl_lines="5 14" import anyio from mcp import Client -from server import mcp - async def show(progress: float, total: float | None, message: str | None) -> None: print(f"{message} ({progress}/{total})") async def main() -> None: - async with Client(mcp) as client: + async with Client("http://localhost:8000/mcp") as client: result = await client.call_tool( "import_catalog", {"urls": ["https://example.com/a.json", "https://example.com/b.json"]}, @@ -56,28 +54,31 @@ anyio.run(main) El callback es una función `async` que recibe exactamente lo que reportó el servidor: `progress`, `total`, `message`. !!! info - `Client(mcp)` se conecta directamente al objeto servidor, en memoria; es el mismo cliente sobre el que se construye la página **[Pruebas](../get-started/testing.md)**. - `progress_callback` es el mismo parámetro sea cual sea el transporte que use el `Client`; - los *tiempos* que vas a ver son los de la conexión en memoria. Ejecuta tu callback - de forma directa, así que cada reporte llega antes de que `call_tool` devuelva. Con un transporte real, - las notificaciones compiten con el resultado, y un callback lento puede seguir ejecutándose después de que `call_tool` - haya devuelto. + `progress_callback` es el mismo parámetro le pases lo que le pases a `Client`: una URL como aquí, un + `StdioServerParameters` o el objeto servidor en una prueba. Eso sí, ten en cuenta los tiempos con un + transporte real. Cada notificación se entrega por su cuenta, al margen de la respuesta, así que un callback + lento puede seguir ejecutándose después de que `call_tool` haya devuelto. Solo la conexión de prueba en el + mismo proceso ejecuta el callback de forma directa y garantiza que cada reporte llegue antes. ### Pruébalo {#try-it} -Pon `client.py` junto a `server.py` y ejecútalo: +Sirve `server.py` por HTTP y luego ejecuta el cliente desde una segunda terminal: + +```console +uv run mcp run server.py --transport streamable-http +``` ```console python client.py ``` ```text -Imported https://example.com/a.json (1/2) -Imported https://example.com/b.json (2/2) +Imported https://example.com/a.json (1.0/2.0) +Imported https://example.com/b.json (2.0/2.0) {'result': 'Imported 2 records.'} ``` -Cada `await ctx.report_progress(...)` en el servidor se convirtió en una llamada a `show` en el cliente, en orden, y ambas líneas se imprimieron **antes** de que `call_tool` devolviera. El progreso no va empaquetado en el resultado; se transmite mientras la herramienta sigue trabajando. +Cada `await ctx.report_progress(...)` en el servidor se convirtió en una llamada a `show` en el cliente, en orden. El progreso no va empaquetado en el resultado. Se transmite mientras la herramienta sigue trabajando. !!! warning `progress_callback` pertenece a la **llamada**, no al `Client`. No hay un argumento del constructor diff --git a/i18n/es/pages/protocol-versions.md b/i18n/es/pages/protocol-versions.md index 9d7211f1..a9f035a9 100644 --- a/i18n/es/pages/protocol-versions.md +++ b/i18n/es/pages/protocol-versions.md @@ -1,6 +1,6 @@ --- translation: - sections: [478fd619e5f90ef8, aef094a00e44e248, bab8cbf3449fa7e9, df1809b15a58335b, 5f9d8c2336ed0239, f54974398e43ddef, b24443dd78584870] + sections: [d98e337bf28845e0, dd642acbba36f615, f547b3e76da19c94, 149513378588da5c, 4e149ed992046709, f54974398e43ddef, b24443dd78584870] tool: 1 --- # Versiones del protocolo {#protocol-versions} @@ -11,9 +11,17 @@ Los servidores publicados antes de 2026-07-28 abren cada conexión con el **hand Casi nunca tienes que preocuparte por esto, porque `Client` negocia por ti. Esta página trata del único argumento del constructor que lo controla, `mode=`, y de las tres ocasiones en que lo cambias. +Cada fragmento de esta página es un `client.py` que habla con el `server.py` de Bookshop de **[El cliente](client/index.md)**. Inicia ese servidor en una terminal: + +```console +uv run mcp run server.py --transport streamable-http +``` + +Luego ejecuta cada fragmento en una segunda terminal con `python client.py`. + ## `mode="auto"` {#modeauto} -```python title="client.py" hl_lines="14-15" +```python title="client.py" hl_lines="7-8" --8<-- "docs_src/protocol_versions/tutorial001.py" ``` @@ -31,13 +39,14 @@ En cualquier caso terminas conectado, y `client.protocol_version` te dice cuál Esa es toda la funcionalidad. Un solo `Client`, servidores de cualquier generación, sin ramificaciones en tu código. !!! info - `MCPServer` responde a `server/discover` en todos los transportes (en memoria, stdio, Streamable - HTTP), así que contra tu propio servidor `auto` siempre llega a `2026-07-28`. El mecanismo de - respaldo solo se activa contra un servidor real anterior a 2026, que es exactamente cuando quieres que lo haga. + `MCPServer` responde a `server/discover` en todos los transportes (Streamable HTTP, stdio y la + conexión en proceso que usan tus pruebas), así que contra tu propio servidor `auto` siempre llega + a `2026-07-28`. El mecanismo de respaldo solo se activa contra un servidor real anterior a 2026, + que es exactamente cuando quieres que lo haga. ## `mode="legacy"` {#modelegacy} -```python title="client.py" hl_lines="14" +```python title="client.py" hl_lines="7" --8<-- "docs_src/protocol_versions/tutorial002.py" ``` @@ -61,7 +70,7 @@ En 2026-07-28 ya no existe. El servidor *devuelve* sus preguntas y tú repites l `mode` también acepta una cadena con una versión moderna del protocolo. Hoy ese conjunto es exactamente `["2026-07-28"]`. -```python title="client.py" hl_lines="14" +```python title="client.py" hl_lines="7" --8<-- "docs_src/protocol_versions/tutorial003.py" ``` @@ -94,7 +103,7 @@ El sondeo es barato, pero sigue siendo una ida y vuelta que pagas en cada recone Así que guárdala. Tras una conexión `auto`, `client.session.discover_result` contiene el `DiscoverResult` exacto que envió el servidor: sus `supported_versions`, sus `capabilities`, sus `instructions` y la identidad que el servidor grabó en el `_meta` del resultado. Devuélveselo como `prior_discover=` la próxima vez: -```python title="client.py" hl_lines="15 17" +```python title="client.py" hl_lines="8 10" --8<-- "docs_src/protocol_versions/tutorial004.py" ``` diff --git a/i18n/es/pages/run/asgi.md b/i18n/es/pages/run/asgi.md index 5d936386..9133ebfa 100644 --- a/i18n/es/pages/run/asgi.md +++ b/i18n/es/pages/run/asgi.md @@ -1,6 +1,6 @@ --- translation: - sections: [1062ef792791488a, 4be2b831547184a9, 374b049e770385f2, b72f6947089e6de0, b172c9db7831bb31, 70b9ece244ca1b0c, cba78e052898c3f6, f06bdb541cb0b469, fb82d526320b7cc3] + sections: [1062ef792791488a, 4be2b831547184a9, 374b049e770385f2, b72f6947089e6de0, b172c9db7831bb31, 10394c6f16601638, cba78e052898c3f6, f06bdb541cb0b469, 6dc898ccc5a903f9] tool: 1 --- # Añadir a una app existente {#add-to-an-existing-app} @@ -100,7 +100,7 @@ Ese `/mcp` final es `streamable_http_path`. Ponlo en `"/"` y el prefijo de monta --8<-- "docs_src/asgi/tutorial004.py" ``` -Ahora los clientes se conectan a `/notes`, no a `/notes/mcp`. +Ahora los clientes se conectan a `/notes/`, no a `/notes/mcp`. ## CORS para clientes de navegador {#cors-for-browser-clients} @@ -143,4 +143,4 @@ Un cliente basado en navegador necesita dos permisos de tu parte: **enviar** sus * Los clientes de navegador necesitan CORS: `allow_headers` para los encabezados de solicitud `Mcp-*`, `expose_headers=["Mcp-Session-Id"]` para la respuesta. * `@mcp.custom_route()` añade endpoints HTTP simples, sin autenticación, junto a `/mcp`. -Una vez que el servidor es accesible en una URL real, **[El cliente](../client/index.md)** se conecta a él con esa URL en lugar de con un objeto servidor. +Una vez que el servidor es accesible en una URL real, **[El cliente](../client/index.md)** se conecta a él con esa URL. diff --git a/i18n/es/pages/run/authorization.md b/i18n/es/pages/run/authorization.md index c39fcdce..432031a1 100644 --- a/i18n/es/pages/run/authorization.md +++ b/i18n/es/pages/run/authorization.md @@ -1,13 +1,13 @@ --- translation: - sections: [d62c13457fc4a534, 80e73abaca6e0652, d1dc4c54cd00ec9c, 14ad3bc7904036bb, 5225f127bc1b9c77, fe1626fdd5aad1da, 4556cb7ea1a04a31] + sections: [d62c13457fc4a534, 80e73abaca6e0652, 128a492d18295f64, 14ad3bc7904036bb, 54a6697833fcc1d9, fe1626fdd5aad1da, 811d083c1da8bcf6] tool: 1 --- # Autorización {#authorization} -Sobre Streamable HTTP, tu servidor MCP es un servicio web común y corriente, y lo proteges igual que proteges cualquier servicio web: con tokens bearer de OAuth 2.1. +Sobre Streamable HTTP tu servidor MCP es un servicio web común y corriente, y lo proteges igual que proteges cualquier servicio web: con tokens bearer de OAuth 2.1. -En términos de OAuth, el servidor es un **servidor de recursos**. Nunca inicia la sesión de nadie y nunca emite un token. Hace una sola cosa: mirar el header `Authorization` de cada solicitud y decidir si el token que trae es válido. +En términos de OAuth, el servidor es un **servidor de recursos**. Nunca inicia la sesión de nadie y nunca emite un token. Hace una sola cosa: mirar el header `Authorization` de cada solicitud y decidir si el token que trae es bueno. Esta página es el lado del servidor. Un cliente que descubre tu servidor de autorización y obtiene el token está en **[Clientes OAuth](../client/oauth-clients.md)**. @@ -21,21 +21,25 @@ Ese es todo el triángulo. Todo lo que hay en esta página es el punto del medio ## Un verificador de tokens {#a-token-verifier} -El SDK no opina sobre cómo debe ser un token válido. Se lo dices tú, implementando **`TokenVerifier`**: +El SDK no tiene opinión sobre cómo es un token válido. Se lo dices tú, implementando **`TokenVerifier`**: -```python title="server.py" hl_lines="12-14 19-24" +```python title="server.py" hl_lines="14-16 21-27" --8<-- "docs_src/authorization/tutorial001.py" ``` -* `TokenVerifier` es un protocolo con un solo método asíncrono. `verify_token` recibe el token en bruto del header `Authorization` y devuelve un **`AccessToken`** si es válido, `None` si no lo es. No hay nada más que implementar. -* Este busca el token en una tabla. Uno real verifica la firma de un JWT o llama al endpoint de introspección de tokens del servidor de autorización. Ese código es tuyo; el SDK solo lo llama. -* `token_verifier=` y `auth=` siempre van juntos. Pasa uno sin el otro y `MCPServer(...)` lanza un `ValueError` antes de atender ninguna solicitud. +* `TokenVerifier` es un protocolo con un único método asíncrono. `verify_token` recibe el token en bruto del header `Authorization` y devuelve un **`AccessToken`** si es válido, `None` si no lo es. No hay nada más que implementar. +* Este busca el token en una tabla; cada entrada registra el recurso para el que se emitió. Uno real verifica la firma de un JWT o llama al endpoint de introspección de tokens del servidor de autorización, e informa para quién se emitió el token (su `aud`) en `AccessToken.resource`. Ese código es tuyo; el SDK solo lo llama. +* `token_verifier=` y `auth=` siempre van juntos. Pasa uno sin el otro y `MCPServer(...)` lanza un `ValueError` antes de atender una sola solicitud. `AuthSettings` es la cara pública de tu servidor de recursos: * `issuer_url`: el servidor de autorización que emite tus tokens. -* `resource_server_url`: la URL pública de este endpoint MCP. Indica *para qué* recurso es un token y es donde vive el documento de descubrimiento. +* `resource_server_url`: la URL pública de este endpoint MCP. Indica para *qué* recurso es un token, y es donde vive el documento de descubrimiento. * `required_scopes`: todo token debe traerlos todos. +* `validate_token_resource`: rechaza cualquier token cuyo `AccessToken.resource` no sea `resource_server_url`. Dejarlo sin definir mientras `resource_server_url` está definido emite un aviso (`MCPDeprecationWarning`) y se comporta como `False`; la versión 3.0 hace que `True` sea el valor por defecto para los servidores de recursos. + * Actívalo cuando tu servidor de autorización vincula los tokens al `resource` que pidió el cliente, que los clientes MCP siempre envían. Mantén `resource_server_url` como la URL exacta a la que se conectan los clientes. + * Déjalo desactivado cuando tu servidor de autorización usa sus propios identificadores de audiencia (un identificador de API de Auth0, un ID de aplicación de Entra) y comprueba `aud` en tu verificador, devolviendo `None` para un token que no sea para este servidor. + * Si `aud` es una lista, pon en `resource` la entrada que sea igual a `resource_server_url`. !!! tip `examples/servers/simple-auth/` en el repositorio del SDK tiene un `IntrospectionTokenVerifier` que llama @@ -43,7 +47,7 @@ El SDK no opina sobre cómo debe ser un token válido. Se lo dices tú, implemen ## Lo que obtienes sobre HTTP {#what-you-get-over-http} -La autorización vive en los headers HTTP, así que solo existe en los transportes HTTP. Ejecútala en el que despliegues: `mcp.run(transport="streamable-http")` la pone en `http://127.0.0.1:8000/mcp`, y **[Ejecutar el servidor](index.md)** tiene el resto. La app ahora tiene dos rutas: +La autorización vive en los headers HTTP, así que solo existe en los transportes HTTP. Ejecútala en el que despliegues: `mcp.run(transport="streamable-http")` la pone en `http://127.0.0.1:8000/mcp`, y **[Ejecutar tu servidor](index.md)** tiene el resto. La app ahora tiene dos rutas: ```text /mcp @@ -65,7 +69,7 @@ Haz un `GET` a esa ruta well-known y obtienes los **Protected Resource Metadata } ``` -Este documento es la forma en que un cliente que nunca ha oído hablar de tu servidor encuentra la entrada: lee `authorization_servers` y va ahí a buscar un token. No escribiste nada de él. +Este documento es la forma en que un cliente que nunca ha oído hablar de tu servidor encuentra el camino de entrada: lee `authorization_servers` y va allí a buscar un token. No escribiste nada de él. !!! check Llama a `/mcp` sin token (o con uno para el que tu verificador devolvió `None`) y la solicitud @@ -78,25 +82,25 @@ Este documento es la forma en que un cliente que nunca ha oído hablar de tu ser {"error": "invalid_token", "error_description": "Authentication required"} ``` - No se analizó nada ni se ejecutó ninguna herramienta. Y ese puntero `resource_metadata` en `WWW-Authenticate` es + No se analizó nada y no se ejecutó ninguna herramienta. Y ese puntero `resource_metadata` en `WWW-Authenticate` es lo que hace automático el descubrimiento: 401 -> documento de metadatos -> servidor de autorización -> token -> reintento. !!! warning - Nada de esto protege a `stdio`. Una tubería no tiene header `Authorization`, así que ahí nunca se - consulta `token_verifier`. La frontera de seguridad de un servidor `stdio` es el proceso que lo lanzó. Lo mismo - vale para el `Client(mcp)` en memoria que usas en las pruebas: se conecta directamente al objeto servidor + Nada de esto protege `stdio`. Una tubería no tiene header `Authorization`, así que `token_verifier` nunca + se consulta ahí. El límite de seguridad de un servidor `stdio` es el proceso que lo lanzó. Lo mismo + vale para el `Client(mcp)` en memoria que usas en las pruebas: se conecta directamente al objeto del servidor y se salta la capa HTTP, autorización incluida. ## La identidad de quien llama {#the-callers-identity} Dentro de cualquier handler, **`get_access_token()`** es el `AccessToken` que tu verificador devolvió para la solicitud actual: -```python title="server.py" hl_lines="4 32-35" +```python title="server.py" hl_lines="4 35-38" --8<-- "docs_src/authorization/tutorial002.py" ``` * Funciona en herramientas, recursos y prompts, y no hay nada que pasar de un lado a otro: el middleware de autenticación lo guarda en una variable de contexto por solicitud. -* Recibes el **mismo objeto que construyó tu verificador**: `client_id`, `scopes`, `subject`, `expires_at` y cualquier `claims` extra que hayas añadido. Ese es el punto de enganche para reglas por herramienta: lee los scopes y rechaza. +* Recibes el **mismo objeto que construyó tu verificador**: `client_id`, `scopes`, `subject`, `expires_at` y cualquier `claims` extra que hayas adjuntado. Ese es el gancho para reglas por herramienta: lee los scopes y rechaza. * Fuera de una solicitud HTTP autenticada devuelve `None`. En memoria y sobre `stdio` siempre es `None`. Llama a `whoami` con `Authorization: Bearer alice-token` y el modelo lee: @@ -109,14 +113,14 @@ alice (scopes: notes:read) El SDK te da la mitad del servidor de recursos: verificar, anunciar, rechazar. No te da una página de inicio de sesión, una pantalla de consentimiento ni un token. -Para ver a las tres partes en movimiento, ejecuta `examples/servers/simple-auth/` del repositorio del SDK (un pequeño servidor de autorización y un servidor de recursos configurado exactamente como en esta página) y luego apunta `examples/clients/simple-auth-client/` hacia él para ver el recorrido completo de descubrimiento y token. +Para ver moverse a las tres partes, ejecuta `examples/servers/simple-auth/` del repositorio del SDK (un pequeño servidor de autorización y un servidor de recursos configurados exactamente como en esta página) y luego apunta `examples/clients/simple-auth-client/` hacia él para ver la coreografía completa de descubrimiento y token. !!! info Hay un segundo argumento del constructor, `auth_server_provider=`, que incrusta un servidor de autorización - completo dentro de tu servidor MCP. Es anterior a la separación AS/RS sobre la que se construye la especificación - de autorización de MCP. Los servidores nuevos no deberían recurrir a él. + completo dentro de tu servidor MCP. Es anterior a la separación AS/RS alrededor de la cual está construida + la especificación de autorización de MCP. Los servidores nuevos no deberían recurrir a él. -Un servidor de autorización también puede aceptar la aserción firmada de un proveedor de identidad empresarial en lugar de que un usuario haga clic en una pantalla de consentimiento, y el SDK admite los dos lados de ese intercambio. El grant, y el cliente que lo presenta, están en **[Aserción de identidad](../client/identity-assertion.md)**. +Un servidor de autorización también puede aceptar la aserción firmada de un proveedor de identidad empresarial en lugar de que un usuario pase por una pantalla de consentimiento, y el SDK admite ambos lados de ese intercambio. El grant, y el cliente que lo presenta, está en **[Aserción de identidad](../client/identity-assertion.md)**. ## Resumen {#recap} @@ -124,7 +128,7 @@ Un servidor de autorización también puede aceptar la aserción firmada de un p * `TokenVerifier` es toda la superficie de integración: un método asíncrono, entra un token, sale `AccessToken | None`. * `token_verifier=` y `auth=AuthSettings(issuer_url=..., resource_server_url=..., required_scopes=[...])` siempre van juntos. * El SDK publica los Protected Resource Metadata de [RFC 9728](https://datatracker.ietf.org/doc/html/rfc9728) en `/.well-known/oauth-protected-resource/...` y responde a las solicitudes no autenticadas con un 401 cuyo header `WWW-Authenticate` apunta a ellos. Ese es todo el mecanismo de descubrimiento. -* `get_access_token()` en cualquier handler te dice quién llama. -* La autorización es un asunto de HTTP. `stdio` y el cliente en memoria nunca la ven. +* `get_access_token()` en cualquier handler es quién está llamando. +* La autorización es asunto de HTTP. `stdio` y el cliente de prueba en memoria nunca la ven. La mitad del cliente (descubrir tu servidor de autorización y obtener el token por ti) está en **[Clientes OAuth](../client/oauth-clients.md)**. Y un cliente que *afirma* una identidad en lugar de pedírsela a un usuario está en **[Aserción de identidad](../client/identity-assertion.md)**. diff --git a/i18n/es/pages/run/deploy.md b/i18n/es/pages/run/deploy.md index 3ff88dc1..53cd02cb 100644 --- a/i18n/es/pages/run/deploy.md +++ b/i18n/es/pages/run/deploy.md @@ -1,6 +1,6 @@ --- translation: - sections: [28221886b198784f, f88ea1f1614f3a1d, ce926d686730b6d0, 3be24f8ad8bb5ab9, 3fad24032b2224ff, f25a7f860e579ecb, e758745df6fb7b0a] + sections: [28221886b198784f, f88ea1f1614f3a1d, 2e76f5cb9df15042, ce926d686730b6d0, 3be24f8ad8bb5ab9, 3fad24032b2224ff, f25a7f860e579ecb, 697b01d95080880d] tool: 1 --- # Desplegar y escalar {#deploy-scale} @@ -47,6 +47,22 @@ Desplegada detrás de un nombre de host real, esa misma configuración por defec desplegado que rechaza todas las conexiones es una lista de hosts permitidos hasta que se demuestre lo contrario. **[Solución de problemas](../troubleshooting.md)** también empieza por aquí. +## Detrás de un proxy que termina TLS {#behind-a-tls-terminating-proxy} + +Si TLS termina en un proxy (un ingress, un balanceador de carga, Caddy, nginx) y uvicorn sirve HTTP plano detrás de él, dile a uvicorn que confíe en los encabezados `X-Forwarded-*` del proxy: + +```console +uvicorn server:app --proxy-headers --forwarded-allow-ips='' +``` + +Sin eso, la app cree que se está sirviendo por `http://`, y cualquier redirección que emita (la habitual es `/mcp` → `/mcp/`) apunta a `http://…`. El cliente de Python se niega a seguir un endpoint HTTPS hacia HTTP plano y lo dice: + +```text +MCPError: Redirect to http://mcp.example.com/mcp/ not followed: it would downgrade this HTTPS endpoint to plain HTTP. +``` + +El parche del lado del cliente es configurar la URL exacta que sirve el servidor (`https://mcp.example.com/mcp/`, barra incluida) para que no haya ninguna redirección. La solución es el flag de arriba. `FORWARDED_ALLOW_IPS` es la forma en variable de entorno; `*` confía en todos los saltos, lo que solo es correcto cuando nada salvo el proxy puede llegar a uvicorn. + ## Workers, y quién tiene que ser sticky {#workers-and-who-has-to-be-sticky} Una vez que el nombre de host responde, pon más de un worker detrás. No hay ningún ajuste del SDK para eso; una app Starlette se escala como cualquier app ASGI, entregando el objeto a algo que sepa hacer fork: @@ -170,6 +186,7 @@ Un `MCPServer` es una implementación del protocolo, no un servidor de aplicacio ## Resumen {#recap} * Por defecto, la app responde solo a las solicitudes dirigidas a localhost. `transport_security=TransportSecuritySettings(allowed_hosts=[...], allowed_origins=[...])` es la puerta de salida a producción: hasta que lo pases, cada solicitud detrás de un nombre de host real es un `421` y el motivo solo está en el log del servidor. +* Detrás de un proxy que termina TLS, ejecuta uvicorn con `--proxy-headers --forwarded-allow-ips=...`, o sus redirecciones apuntarán a `http://` y el cliente las rechazará. * En 2026-07-28 no hay sesión ni nada sobre lo que un balanceador de carga pueda ser sticky. `stateless_http=True` es un ajuste solo para lo heredado porque una solicitud moderna se enruta y se responde antes de que ese flag llegue a leerse. * La clave por defecto de `requestState` es `os.urandom(32)`, acuñada por proceso. Un reintento de varias idas y vueltas que llega a otro worker falla con `-32602` *"Invalid or expired requestState"*. * La solución es `RequestStateSecurity(keys=[...])` **y** el mismo nombre de servidor en cada instancia. El nombre es el claim de audiencia por defecto del token. Las mismas claves, el mismo nombre. diff --git a/i18n/es/pages/run/index.md b/i18n/es/pages/run/index.md index a8964b70..8dd948d0 100644 --- a/i18n/es/pages/run/index.md +++ b/i18n/es/pages/run/index.md @@ -1,6 +1,6 @@ --- translation: - sections: [fea8d769ff9edeba, ce8e2ad42f29ef71, 0d705efb19cf99c2, 2fd7cf825e6d2b2c, 9adc400e8c88e854, 318893ad8e2e9924, 6b63ab96b34476c0] + sections: [fea8d769ff9edeba, ce8e2ad42f29ef71, 0d705efb19cf99c2, 9fd2357154a5b7e7, 9adc400e8c88e854, 318893ad8e2e9924, 6b63ab96b34476c0] tool: 1 --- # Ejecutar el servidor {#running-your-server} @@ -75,6 +75,11 @@ Cada transporte tiene sus propios argumentos nombrados, todos en `run()`: * `max_request_body_size`: el cuerpo de solicitud más grande que se acepta, en bytes. Es 4 MiB por defecto; las solicitudes mayores reciben HTTP 413 antes del análisis o de la creación de la sesión. Súbelo solo cuando los mensajes MCP legítimos superen ese tamaño. +* `session_idle_timeout`: segundos que una sesión heredada puede pasar sin nada en curso antes de que el + servidor la cierre. Por defecto 1800. `None` lo desactiva. Consulta + [Duración de la sesión y límites](legacy-clients.md#session-lifetime-and-limits). +* `max_sessions`: cuántas sesiones heredadas mantiene un proceso a la vez. Por defecto 10 000. `None` + quita el límite. Se trata en la misma sección. * `event_store`, `retry_interval`, `transport_security`: reanudabilidad y protección contra DNS rebinding. Pueden esperar hasta que despliegues en algún lugar que no sea localhost; **[Desplegar y escalar](deploy.md)** cubre `transport_security`. !!! warning diff --git a/i18n/es/pages/run/legacy-clients.md b/i18n/es/pages/run/legacy-clients.md index 8b1e2bf2..f7a1414c 100644 --- a/i18n/es/pages/run/legacy-clients.md +++ b/i18n/es/pages/run/legacy-clients.md @@ -1,6 +1,6 @@ --- translation: - sections: [3d1663c18edc824c, d4fd37009a13f03d, af9f398a5a8b679a, 470c2dd144294d69, 8e45827e6d24e8c8, 91dfd0ce98ebb03c] + sections: [3d1663c18edc824c, 90956965ae6a1ca1, af9f398a5a8b679a, 5ce83b1f9d88da62, 0d9b5d13fffc94e5, 8e45827e6d24e8c8, 91dfd0ce98ebb03c] tool: 1 --- # Atender clientes heredados {#serving-legacy-clients} @@ -21,15 +21,25 @@ Así que un cliente heredado (legacy) no es algo *para* lo que construyes. Es al ## Un handler, ambas generaciones {#one-handler-both-eras} -Aquí tienes una herramienta que necesita preguntarle algo al usuario, y clientes de ambas generaciones que la llaman: +Aquí tienes una herramienta que necesita preguntarle algo al usuario: -```python title="server.py" hl_lines="24 37-38" +```python title="server.py" hl_lines="21" --8<-- "docs_src/legacy_clients/tutorial001.py" ``` `reserve` necesita una cosa que el modelo no proporcionó: cuántos ejemplares. `Annotated[..., Resolve(ask_quantity)]` es la forma en que una herramienta lo declara (**[Dependencias](../handlers/dependencies.md)** tiene todos los detalles). Nada en `reserve` nombra una versión, comprueba una capacidad ni se bifurca. -Los dos clientes están abiertos **al mismo tiempo**, sobre el mismo objeto `mcp`. `mode="legacy"` ejecuta el handshake `initialize`: exactamente la conexión que abre un cliente anterior a 2026. El otro toma el valor por defecto y queda en `2026-07-28`. +Sírvela por HTTP, y aquí tienes clientes de ambas generaciones que la llaman: + +```console +uv run mcp run server.py --transport streamable-http +``` + +```python title="client.py" hl_lines="14-15" +--8<-- "docs_src/legacy_clients/tutorial001_client.py" +``` + +Los dos clientes están abiertos **al mismo tiempo**, contra el mismo servidor en ejecución. `mode="legacy"` ejecuta el handshake `initialize`: exactamente la conexión que abre un cliente anterior a 2026. El otro toma el valor por defecto y queda en `2026-07-28`. Ejecuta `python client.py` desde una segunda terminal: ```text 2025-11-25 {'result': "Reserved 2 of 'Dune'."} @@ -62,6 +72,41 @@ Con un solo worker eso no se nota. Con dos, es todo el problema: una solicitud q perdidos a un cliente que se reconecta a la *misma* sesión), no un almacén de sesiones. Nunca hace que una sesión sea alcanzable desde otro proceso. +## Duración y límites de la sesión {#session-lifetime-and-limits} + +Una sesión heredada no vive para siempre, y un proceso no mantiene un número ilimitado de +ellas. Dos ajustes controlan esto. Ambos son argumentos nombrados de `run()`, `streamable_http_app()` +y `Server.streamable_http_app()`. Las conexiones modernas (`2026-07-28`) y `stateless_http=True` +no tienen sesiones, así que ninguno de los dos ajustes se les aplica. + +| Ajuste | Por defecto | Qué hace | Qué ve el cliente | Desactivarlo | +|---|---|---|---|---| +| `session_idle_timeout` | `1800` (30 min) | Cierra una sesión que no ha tenido nada en curso durante ese tiempo. | `404 Session not found`. Tiene que volver a hacer `initialize`. | `None` | +| `max_sessions` | `10_000` | Se niega a abrir una sesión más allá de esa cantidad. Las sesiones existentes no se tocan y no se expulsa ninguna. | `503 Too many open sessions` con el código JSON-RPC `-32603`. | `None` | + +Qué cuenta como "en curso": + +* Un stream `GET` abierto. Los clientes del SDK mantienen uno abierto, así que la sesión de un + cliente conectado nunca expira. +* Una solicitud que todavía se está respondiendo. Una llamada a herramienta que dura más que el + timeout no se interrumpe, y el plazo solo empieza a contar cuando termina. +* Nada más. Entre solicitudes el reloj corre. Cualquier solicitud en la sesión lo reinicia, + `ping` incluido. Una vez que una sesión ha expirado, nada la revive. + +Un cliente que termina su sesión con `DELETE` la libera de inmediato. También uno cuya +solicitud de apertura fue rechazada. + +```python +mcp.run(transport="streamable-http", session_idle_timeout=None, max_sessions=50_000) +``` + +Ambos eventos aparecen en el log del servidor. Una expiración es `Session idle timeout` en +nivel `INFO`. Una apertura rechazada es `Refusing to open a new session: sessions are already open` +en nivel `WARNING`. + +Los límites son por proceso. Con cuatro workers el techo es cuatro veces `max_sessions`, y cada +worker expira sus propias sesiones. + ## El único ajuste: `stateless_http` {#the-one-knob-stateless_http} Si la afinidad es un costo que te niegas a pagar, hay exactamente una cosa que puedes cambiar. @@ -87,7 +132,7 @@ Dos cosas sobre él importan más que lo que hace. !!! check Haz lo incorrecto. `reserve` es exactamente la herramienta que acaba de atender a ambos clientes. - Despliégala con `stateless_http=True`, conecta los mismos dos clientes por HTTP y llámala desde + Despliégala con `stateless_http=True`, conecta los mismos dos clientes y llámala desde cada uno. El cliente moderno sigue recibiendo `Reserved 2 of 'Dune'.` El tramo moderno no cambió. diff --git a/i18n/es/pages/troubleshooting.md b/i18n/es/pages/troubleshooting.md index 3779ff72..04bf6378 100644 --- a/i18n/es/pages/troubleshooting.md +++ b/i18n/es/pages/troubleshooting.md @@ -1,6 +1,6 @@ --- translation: - sections: [2efaecdef109a5c5, fcacd3e66b8635a4, 25323d737dcf0261, 8a6e351ec756904d, 137454d469c867f5, 6392596bd6df54f0, 41126fa9c4fe432f, 480b6d7897e30ab4, d83bb682e708dde0, ebbed3449c499db4, 323ef84f6b4bebde, 30fd31be74169d9a, 656943c6cb567218, c2dc3b1007d2e987, 7cf5386b997d04e9, 0b59feed8384456e, 0cba47bae78d04eb, e4355f4c7cf4fb2e] + sections: [3d58228e81b99543, 170514ce901c4139, 17d61fad0a50d62b, 8a6e351ec756904d, 137454d469c867f5, 6392596bd6df54f0, 41126fa9c4fe432f, 480b6d7897e30ab4, d83bb682e708dde0, ebbed3449c499db4, 525cdf1755e29d4c, 30fd31be74169d9a, d2e88333d4f7841f, c2dc3b1007d2e987, d6eabf60cc366341, f798e815252852c2, 0cba47bae78d04eb, 2c218ba829abf74e] tool: 1 --- # Solución de problemas {#troubleshooting} @@ -13,6 +13,12 @@ Varias entradas se ejecutan contra este mismo servidor. Una herramienta y un rec --8<-- "docs_src/troubleshooting/tutorial001.py" ``` +Esas entradas se conectan a él en `http://localhost:8000/mcp`, así que déjalo ejecutándose por HTTP: + +```console +uv run mcp run server.py --transport streamable-http +``` + Los errores que cita esta página son reales: la propia suite de pruebas del SDK reproduce cada uno de ellos. ## `ExceptionGroup: unhandled errors in a TaskGroup (1 sub-exception)` {#exceptiongroup-unhandled-errors-in-a-taskgroup-1-sub-exception} @@ -23,7 +29,7 @@ Esto no es un error de MCP. Es ruido de anyio, y tu error real es la **última l ```python async def main() -> None: - async with Client(mcp) as client: + async with Client("http://localhost:8000/mcp") as client: await client.read_resource("weather://Atlantis") ``` @@ -49,7 +55,7 @@ Dos cosas que hacer con eso: ```python async def main() -> None: - async with Client(mcp) as client: + async with Client("http://localhost:8000/mcp") as client: try: await client.read_resource("weather://Atlantis") except MCPError as e: @@ -67,7 +73,7 @@ async def main() -> None: ```python async def main() -> None: - client = Client(mcp) + client = Client("http://localhost:8000/mcp") tools = await client.list_tools() # RuntimeError ``` @@ -75,7 +81,7 @@ Entra en él. `__aenter__` es la conexión: ```python async def main() -> None: - async with Client(mcp) as client: + async with Client("http://localhost:8000/mcp") as client: tools = await client.list_tools() ``` @@ -251,7 +257,7 @@ app = Starlette(routes=[Mount("/", app=mcp.streamable_http_app())], lifespan=lif ## `MCPError: Session not found` {#mcperror-session-not-found} -El servidor no reconoce el `Mcp-Session-Id` que envió tu cliente, casi siempre porque el servidor **se reinició** (o te enrutaron a otra instancia). Las sesiones viven en la memoria de ese único proceso. +El servidor no reconoce el `Mcp-Session-Id` que envió tu cliente. O bien el servidor **se reinició** (o te enrutaron a otra instancia), o bien la sesión **expiró** porque no hubo nada en curso durante `session_idle_timeout`, que es de 30 minutos por defecto. Consulta [Duración y límites de la sesión](run/legacy-clients.md#session-lifetime-and-limits). Las sesiones viven en la memoria de ese único proceso. No hay ningún bug del servidor que encontrar. La respuesta HTTP es un `404` cuyo cuerpo *sí* es JSON-RPC, así que, a diferencia del `421` de arriba, el `Client` de python te muestra este tal cual: @@ -261,9 +267,9 @@ No hay ningún bug del servidor que encontrar. La respuesta HTTP es un `404` cuy La solución es reconectar: sal del bloque `async with Client(...)` y entra en uno nuevo, que negocia una sesión nueva. Para un cliente de larga duración, eso significa capturar `MCPError` alrededor de tus llamadas y reconectar ante este mensaje en lugar de reintentar dentro de una sesión muerta. -Si ocurre *sin* un reinicio, estás ejecutando más de un worker sin sticky sessions: cada worker mantiene su propia tabla de sesiones, así que una solicitud enrutada al equivocado acaba aquí. **[Desplegar y escalar](run/deploy.md)** y **[Atender clientes heredados](run/legacy-clients.md)** tienen todos los detalles y las dos soluciones (enrutamiento sticky o `stateless_http=True`). +Si ocurre *sin* un reinicio y sin que el cliente haya estado en silencio tanto tiempo, estás ejecutando más de un worker sin sticky sessions: cada worker mantiene su propia tabla de sesiones, así que una solicitud enrutada al equivocado acaba aquí. **[Desplegar y escalar](run/deploy.md)** y **[Atender clientes heredados](run/legacy-clients.md)** tienen todos los detalles y las dos soluciones (enrutamiento sticky o `stateless_http=True`). -Para quien opera el servidor, la línea de log correspondiente es `Rejected request with unknown or expired session ID: `. Se registra con nivel `INFO`, así que es invisible con el umbral habitual de `WARNING`. Verla en ráfagas justo después de un despliegue es normal; todos los clientes conectados están reconectando. +Para quien opera el servidor, la línea de log correspondiente es `Rejected request with unknown or expired session ID: `. Se registra con nivel `INFO`, así que es invisible con el umbral habitual de `WARNING`. Verla en ráfagas justo después de un despliegue es normal; todos los clientes conectados están reconectando. Cuando lo que ocurrió es que la sesión expiró, a esa línea la precede `Session idle timeout`, también con nivel `INFO`. ## `MCPError: Method not found` {#mcperror-method-not-found} @@ -275,7 +281,13 @@ Hay una cosa que **no** produce este error, aunque es una solicitud que el proto Tu servidor quiere preguntarle algo al usuario, y este cliente nunca dijo que se le pudiera preguntar. -Un resolutor de elicitación (elicitation) se niega de entrada cuando el cliente conectado no declaró la elicitación por formulario, y `e.error.data` nombra exactamente lo que falta: +Este Bistro pregunta antes de reservar, mediante un resolutor: + +```python title="server.py" hl_lines="15-17 21" +--8<-- "docs_src/troubleshooting/tutorial007.py" +``` + +Sírvelo en lugar del servidor Weather y llama a `book_table` desde un cliente que no pasó ningún `elicitation_callback`. El resolutor se niega de entrada, porque el cliente conectado nunca declaró la elicitación (elicitation) por formulario, y `e.error.data` nombra exactamente lo que falta: ```json { @@ -289,7 +301,7 @@ Pasa `elicitation_callback=` a `Client(...)`. Registrar el callback *es* la decl ```python async def main() -> None: - async with Client(mcp, elicitation_callback=handle_elicitation) as client: + async with Client("http://localhost:8000/mcp", elicitation_callback=handle_elicitation) as client: result = await client.call_tool("book_table", {"date": "Friday"}) ``` @@ -314,14 +326,14 @@ Este lo ves desde `ctx.elicit()` en una conexión heredada, y en cualquier conex Tu handler intentó contactar con el cliente a mitad de solicitud, en una conexión cuya llamada no tiene ningún canal capaz de llevar una solicitud desde el servidor. Hay tres configuraciones de servidor que ponen una llamada en esa situación. -**Una conexión `2026-07-28`: cualquier transporte, siempre.** El protocolo moderno no tiene solicitudes iniciadas por el servidor en absoluto, así que el servidor se niega antes de enviar nada. `ctx.elicit()` dentro de una herramienta es la forma clásica de toparse con esto (en la primera prueba en memoria, ya que `Client(server)` negocia `2026-07-28` sin que se lo pidas), y pasar `elicitation_callback=` no cambia nada, porque ninguna solicitud llega nunca al cliente para que la responda: +**Una conexión `2026-07-28`: cualquier transporte, siempre.** El protocolo moderno no tiene solicitudes iniciadas por el servidor en absoluto, así que el servidor se niega antes de enviar nada. `ctx.elicit()` dentro de una herramienta es la forma clásica de toparse con esto, normalmente en la primerísima **[prueba](get-started/testing.md)** en memoria de esa herramienta, ya que `Client(mcp)` negocia `2026-07-28` sin que se lo pidas. Pasar `elicitation_callback=` no cambia nada, porque ninguna solicitud llega nunca al cliente para que la responda: ```python title="server.py" hl_lines="16" --8<-- "docs_src/troubleshooting/tutorial006.py" ``` ```python -async def main() -> None: +async def test_book_table() -> None: async with Client(mcp) as client: await client.call_tool("book_table", {"date": "Friday"}) ``` @@ -363,7 +375,7 @@ El servidor no pudo verificar el token `requestState` que tu cliente devolvió c ```python async def main() -> None: - async with Client(mcp) as client: + async with Client("http://localhost:8000/mcp") as client: await client.call_tool("forecast", {"city": "London"}, request_state="round-1-from-worker-a") ``` @@ -416,7 +428,7 @@ mcp = MCPServer("Weather", request_state_security=RequestStateSecurity(keys=[key * `Tool already exists:` en el log del servidor es la única señal de que dos herramientas con el mismo nombre se fundieron en una. * Un 421, tres formas de escribirlo: `Server returned an error response` (el `Client` de python), `421 Misdirected Request` / `Invalid Host header` (todo lo demás), `Invalid Host header: ` (el log del servidor). Solución: `transport_security=TransportSecuritySettings(allowed_hosts=[...])`. * `Task group is not initialized` -> una app montada cuyo lifespan de la app host nunca entró en `mcp.session_manager.run()`. -* `Session not found` -> el servidor se reinició; reconecta. +* `Session not found` -> el servidor se reinició o la sesión expiró (`session_idle_timeout`); reconecta. * `Cannot send 'elicitation/create': ... no back-channel ...` -> `ctx.elicit()` necesita un canal de servidor a cliente: una conexión `2026-07-28` nunca lo tiene, `stateless_http=True` quita el heredado y `json_response=True` quita el ligado a la solicitud. Usa un resolutor (un cliente heredado necesita además un servidor que conserve el canal). Su vecino `Method not found` es una solicitud de un método que la revisión del protocolo del otro lado no tiene. * `Client did not declare the form elicitation capability ...` y `Elicitation not supported` -> al cliente le falta `elicitation_callback=`. * `Invalid or expired requestState` nunca dice por qué en lo que se transmite. El log del servidor sí; `unknown key` significa compartir `RequestStateSecurity(keys=[...])` entre los workers. diff --git a/i18n/es/pages/whats-new.md b/i18n/es/pages/whats-new.md index 417f1eed..01a07527 100644 --- a/i18n/es/pages/whats-new.md +++ b/i18n/es/pages/whats-new.md @@ -1,6 +1,6 @@ --- translation: - sections: [cfe01c0c5863dfa2, 1c58c5cfcc37d455, a7392996acf1ad8f, 875eb2889263424e] + sections: [cfe01c0c5863dfa2, 0dbb68d8b210177b, 80cf193023af3ed4, 875eb2889263424e] tool: 1 --- # Novedades de la v2 {#whats-new-in-v2} @@ -43,11 +43,11 @@ No todo lo que una herramienta necesita debería venir del modelo. Novedad de la La v1 te entregaba tres capas anidadas: un gestor de contexto de transporte que producía flujos en crudo, una `ClientSession` envolviéndolos y un `await session.initialize()` llamado a mano. La v2 tiene un solo objeto: -```python title="client.py" hl_lines="14-18" ---8<-- "docs_src/client/tutorial001.py" +```python title="client.py" hl_lines="7-11" +--8<-- "docs_src/client/tutorial001_client.py" ``` -`Client` acepta un objeto servidor (en memoria, sin transporte: la historia de las pruebas), una URL (Streamable HTTP), un `StdioServerParameters` (un subproceso stdio) o cualquier otro gestor de contexto de transporte como `sse_client(...)`. Entrar en `async with` conecta y negocia la versión del protocolo, sea cual sea la generación que hable el servidor; `client.server_capabilities` y `client.protocol_version` simplemente están ahí después, y `client.server_info` también cuando el servidor se identifica (ahora es `Implementation | None`, porque la identidad en la generación 2026 es opcional). Los callbacks de muestreo y elicitación que registraste en la v1 siguen funcionando (sus cuerpos ven el mismo renombramiento de atributos a snake_case que todo lo demás en esta página), ahora también responden a las solicitudes dentro de resultados al estilo 2026 (más abajo) y se ejecutan concurrentemente en lugar de una a una. `ClientSession` sigue debajo para quien quiera la superficie de bajo nivel, y `client.session` te la entrega; también cambió (se ejecuta sobre el nuevo motor de despacho, y algunas de sus propias firmas cambiaron), así que lee la **[Guía de migración](migration.md#clientsession-now-runs-on-jsonrpcdispatcher-basesession-removed)** antes de bajar a ese nivel. +`Client` acepta una URL (Streamable HTTP), un `StdioServerParameters` (un subproceso stdio), cualquier otro gestor de contexto de transporte como `sse_client(...)` o, en las pruebas, el propio objeto servidor (en memoria, sin transporte). Entrar en `async with` conecta y negocia la versión del protocolo, sea cual sea la generación que hable el servidor; `client.server_capabilities` y `client.protocol_version` simplemente están ahí después, y `client.server_info` también cuando el servidor se identifica (ahora es `Implementation | None`, porque la identidad en la generación 2026 es opcional). Los callbacks de muestreo y elicitación que registraste en la v1 siguen funcionando (sus cuerpos ven el mismo renombramiento de atributos a snake_case que todo lo demás en esta página), ahora también responden a las solicitudes dentro de resultados al estilo 2026 (más abajo) y se ejecutan concurrentemente en lugar de una a una. `ClientSession` sigue debajo para quien quiera la superficie de bajo nivel, y `client.session` te la entrega; también cambió (se ejecuta sobre el nuevo motor de despacho, y algunas de sus propias firmas cambiaron), así que lee la **[Guía de migración](migration.md#clientsession-now-runs-on-jsonrpcdispatcher-basesession-removed)** antes de bajar a ese nivel. **[El Client](client/index.md)** lo presenta, **[Transportes del cliente](client/transports.md)** cubre las cuatro formas de conexión, **[Callbacks del cliente](client/callbacks.md)** cubre los callbacks en sí y **[Pruebas](get-started/testing.md)** muestra el patrón en memoria que sustituye al helper `create_connected_server_and_client_session()` de la v1. @@ -172,11 +172,15 @@ Toda solicitud iniciada por el servidor desaparece en 2026-07-28: elicitación p El reemplazo le da la vuelta a la llamada. Una herramienta que necesita algo del usuario *devuelve* la pregunta (`InputRequiredResult`), el cliente la responde con los mismos callbacks que siempre tuvo, y la llamada se reintenta con las respuestas adjuntas. `Client` dirige ese bucle por ti. En el servidor rara vez construyes tú el resultado, porque lo hace una **[dependencia](handlers/dependencies.md)**: anota un parámetro con `Resolve(ask_quantity)`, donde `ask_quantity` es una función ordinaria que escribes tú, y el SDK pregunta por el mecanismo que la conexión admita, una solicitud de elicitación en vivo en una sesión heredada o una solicitud de varias idas y vueltas en 2026. Un solo cuerpo de herramienta, ambas generaciones: -```python title="dual_era.py" hl_lines="24 37-38" +```python title="server.py" hl_lines="21" --8<-- "docs_src/legacy_clients/tutorial001.py" ``` -Ese archivo es la propuesta en un solo lugar: un servidor, una herramienta respaldada por `Resolve`, y un cliente heredado más un cliente moderno recibiendo ambos su respuesta, en memoria. **[Solicitudes de varias idas y vueltas](handlers/multi-round-trip.md)** explica el mecanismo (incluido `request_state`, que el SDK sella y verifica por ti); **[Elicitación](handlers/elicitation.md)** cubre cómo preguntar. +```python title="client.py" hl_lines="14-15" +--8<-- "docs_src/legacy_clients/tutorial001_client.py" +``` + +Esos dos archivos son toda la propuesta: un servidor, una herramienta respaldada por `Resolve`, y un cliente heredado más un cliente moderno recibiendo ambos su respuesta del mismo servidor en ejecución (**[Atender clientes heredados](run/legacy-clients.md)** los recorre). **[Solicitudes de varias idas y vueltas](handlers/multi-round-trip.md)** explica el mecanismo (incluido `request_state`, que el SDK sella y verifica por ti); **[Elicitación](handlers/elicitation.md)** cubre cómo preguntar. !!! warning "Este es el único lugar donde un servidor v1 portado cambia de comportamiento" Tus propias pruebas se lo encuentran primero: `Client(mcp)` negocia 2026-07-28 contra tu diff --git a/i18n/fr/pages/advanced/apps.md b/i18n/fr/pages/advanced/apps.md index 8e19e4db..414eee7e 100644 --- a/i18n/fr/pages/advanced/apps.md +++ b/i18n/fr/pages/advanced/apps.md @@ -1,6 +1,6 @@ --- translation: - sections: [0355618e5f4d5fe4, 1821eaf50f2d0b64, 82e0b28ebd3abf5a, 8ac39614c094f2d0, dab6ff945501ab2a, bd5565c3b2d4f959, 96819ce3d63a0487] + sections: [0355618e5f4d5fe4, 0fefa31fb7c7b585, 5c53e7487c9c70cc, 8ac39614c094f2d0, dab6ff945501ab2a, bd5565c3b2d4f959, 96819ce3d63a0487] tool: 1 --- # MCP Apps {#mcp-apps} @@ -18,7 +18,7 @@ Le SDK fournit cela sous la forme de l’extension intégrée `Apps` (`io.modelc ## Une horloge avec un cadran {#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" ``` @@ -31,7 +31,7 @@ Quatre étapes : Le HTML lui-même écoute le `postMessage` de l’hôte et affiche le résultat. Pour de vraies applications, utilisez dans votre HTML le SDK navigateur officiel [`@modelcontextprotocol/ext-apps`](https://github.com/modelcontextprotocol/ext-apps). Il vous donne `ontoolresult`, `callServerTool`, `getHostContext` et `onhostcontextchanged` au lieu d’événements de message bruts. -## Dégradation gracieuse {#graceful-degradation} +## Dégradation élégante {#graceful-degradation} Tous les clients n’affichent pas les apps. La spécification dit sans détour ce que cela implique pour vous : @@ -39,11 +39,31 @@ Tous les clients n’affichent pas les apps. La spécification dit sans détour Le modèle lit `content` ; l’iframe est pour les humains. Un hôte capable d’afficher une interface transmet quand même le résultat textuel au modèle, et un client purement textuel ne reçoit *que* cela. Le schéma canonique est donc : un outil, deux réponses. Regardez à nouveau `get_time` : -```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)` ne vaut `True` que lorsque le client a déclaré l’extension `io.modelcontextprotocol/ui` **et** listé `text/html;profile=mcp-app` dans ses paramètres `mimeTypes`. Le champ est obligatoire, donc un client qui l’omet ne compte pas. C’est exactement ce que déclare `main()` dans le même fichier : la moitié client de la négociation, et la réponse riche revient. +`client_supports_apps(ctx)` ne vaut `True` que lorsque le client a déclaré l’extension `io.modelcontextprotocol/ui` **et** listé `text/html;profile=mcp-app` dans ses paramètres `mimeTypes`. Le champ est obligatoire, donc un client qui l’omet ne compte pas. Voici la moitié client de la négociation : + +```python title="client.py" hl_lines="8 12" +--8<-- "docs_src/apps/tutorial001_client.py" +``` + +Servez `server.py` en HTTP, puis lancez le client depuis un second terminal : + +```console +uv run mcp run server.py --transport streamable-http +``` + +```console +python client.py +``` + +```text +2026-06-26T12:00:00Z +``` + +La réponse riche est revenue. Retirez `extensions=[APPS_SUPPORT]` de l’appel à `Client` et le même programme affiche `The time is 2026-06-26T12:00:00Z.` à la place, c’est-à-dire tout ce qu’un client purement textuel voit jamais. !!! warning Ne renvoyez jamais un texte de substitution comme `"[Rendered UI]"` pour seul contenu. Si le texte de repli est inutile, l’outil est inutile pour tout client purement textuel et pour le modèle lui-même. Écrivez la phrase. diff --git a/i18n/fr/pages/advanced/extensions.md b/i18n/fr/pages/advanced/extensions.md index 2ecdc7b2..bea862f3 100644 --- a/i18n/fr/pages/advanced/extensions.md +++ b/i18n/fr/pages/advanced/extensions.md @@ -1,6 +1,6 @@ --- translation: - sections: [05891e7cc1938a13, b3c01a6af28c51ee, 7ffc91f5e38bdfe0, 717d3f235a8333a7, f471a13b2fe5d737, ed6af2df4b656dff] + sections: [05891e7cc1938a13, b3c01a6af28c51ee, 6eba6ce094f4417e, 125f28588c85dd7b, ddd8969b698ca11c, ed6af2df4b656dff] tool: 1 --- # Extensions {#extensions} @@ -49,7 +49,7 @@ Utilisez comme préfixe un domaine que vous contrôlez. `io.modelcontextprotocol La plus petite extension utile, c’est un outil et une table de paramètres : -```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" ``` @@ -57,17 +57,23 @@ La plus petite extension utile, c’est un outil et une table de paramètres : * `settings()` est la valeur annoncée sous `capabilities.extensions["com.example/stamps"]`. Renvoyez `{}` (la valeur par défaut) pour annoncer l’extension sans paramètres. * L’extension ne reçoit jamais le serveur. Elle déclare ses contributions sous forme de données ; `MCPServer` les consomme. Il n’y a pas de `self.server` à modifier. -Et `main()` en est la preuve, un client en mémoire branché directement sur `mcp` : +Servez-la en HTTP, et un client en est la preuve : -```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" +``` + +Chaque `server.py` de cette page est servi avec cette commande, et chaque `client.py` tourne à côté, lancé avec `python client.py` depuis un second terminal. + ### Servir vos propres méthodes {#serving-your-own-methods} Une extension peut enregistrer de **nouvelles méthodes de requête** : ses propres verbes, servis à côté de ceux de la spécification : -```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" ``` @@ -83,14 +89,15 @@ Les méthodes sont **strictement additives**. Le SDK le fait respecter à la con ### Le côté client {#the-client-side} -Le `main()` du même fichier raconte toute l’histoire côté client, ses deux moitiés : +Le client est un programme à part entière, et il porte les deux moitiés de l’histoire côté client : -```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)])` déclare l’extension. Les déclarations deviennent `ClientCapabilities.extensions` : sur une connexion 2026-07-28, la table voyage dans l’enveloppe `_meta` de chaque requête, donc le serveur la voit sur **chaque** requête ; sur une connexion historique, elle transite par la poignée de main `initialize`. Le code serveur ne s’en soucie pas : `require_client_extension(ctx, ...)` et `ctx.session.check_client_capability(...)` lisent la bonne source dans les deux cas. * Les méthodes propres à un éditeur descendent d’un niveau, vers `client.session.send_request(...)` ; `Client` n’acquiert de méthodes de premier rang que pour les verbes de la spécification. `send_request` accepte n’importe quelle sous-classe de `Request`, donc la requête de l’éditeur passe telle quelle. +* `SearchRequest` et les deux modèles qu’elle transporte constituent le contrat de liaison de l’extension, donc le client les déclare de son côté. Une extension publiée les fournirait dans un paquet que les deux côtés importent. ### Intercepter `tools/call` {#intercepting-toolscall} @@ -109,12 +116,18 @@ Le hook enveloppe `tools/call` et rien d’autre. Pour ce qui concerne chaque me ## Utiliser une extension client {#using-a-client-extension} -Une **extension client**, c’est le même contrat vu du côté consommateur : un ensemble de comportements côté client derrière un seul identifiant. Passez des instances à `Client(extensions=[...])` et appelez les outils normalement : +Une **extension client**, c’est le même contrat vu du côté consommateur : un ensemble de comportements côté client derrière un seul identifiant. Ici, le serveur répond à `buy` par un reçu à échanger au lieu de la marchandise, et seulement pour un client qui a déclaré l’extension : -```python title="client.py" hl_lines="66-68" +```python title="server.py" hl_lines="22-25" --8<-- "docs_src/extensions/tutorial006.py" ``` +Côté client, passez des instances à `Client(extensions=[...])` et appelez les outils normalement : + +```python title="client.py" hl_lines="33-35" +--8<-- "docs_src/extensions/tutorial006_client.py" +``` + `call_tool("buy", ...)` renvoie un simple `CallToolResult`, comme tout autre appel. Ce que l’extension a changé : le serveur peut désormais répondre à `buy` par une **forme de résultat** `receipt` au lieu d’un résultat final, et `Receipts` la termine (ici en échangeant le reçu via un appel de suivi) avant que `call_tool` ne renvoie. Rien ne bouge au point d’appel. Retirez l’extension et rien de tout cela n’existe : le garde-fou du serveur refuse un client qui ne l’a pas déclarée (erreur -32021), et une forme revendiquée venant d’un serveur qui saute le garde-fou échoue à la validation, exactement comme la spécification l’exige pour un `resultType` non reconnu. Désactivé par défaut, aux deux bouts de la liaison. @@ -124,15 +137,15 @@ Pour annoncer un identifiant **sans aucun** comportement côté client (le serve ```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")]) ``` ## Écrire une extension client {#writing-a-client-extension} Dérivez `ClientExtension` et ne redéfinissez que ce dont vous avez besoin. Trois types de contributions, chacun avec une valeur par défaut : `settings()`, `claims()` et `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" ``` * L’identifiant suit la même grammaire que celui du serveur, validé au moment où la classe est définie. @@ -153,12 +166,18 @@ Deux règles discrètes. Les claims ne sont actifs que sur les connexions 2026-0 ### Verbes d’extension {#extension-verbs} -Les méthodes de requête propres à une extension n’ont besoin d’aucun enregistrement côté client. Un type de requête d’éditeur dérive de `mcp.types.Request` et passe par `client.session.send_request`, comme dans [Servir vos propres méthodes](#serving-your-own-methods). Un ajout : lorsqu’une clé des paramètres doit transiter par l’en-tête `Mcp-Name` (des spécifications d’extension comme tasks l’exigent pour leurs verbes), le type de requête déclare `name_param` : +Les méthodes de requête propres à une extension n’ont besoin d’aucun enregistrement côté client. Un type de requête d’éditeur dérive de `mcp.types.Request` et passe par `client.session.send_request`, comme dans [Servir vos propres méthodes](#serving-your-own-methods). Prenez un serveur dont l’extension sert un seul verbe portant sur un job nommé : -```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" ``` +Un ajout côté client : lorsqu’une clé des paramètres doit transiter par l’en-tête `Mcp-Name` (des spécifications d’extension comme tasks l’exigent pour leurs verbes), le type de requête déclare `name_param` : + +```python title="client.py" hl_lines="20-23 28-29" +--8<-- "docs_src/extensions/tutorial007_client.py" +``` + La session reflète `params["jobId"]` dans `Mcp-Name` sur chaque chemin d’envoi, et une valeur manquante échoue bruyamment au lieu d’omettre silencieusement un en-tête obligatoire. ## Ce qu’une extension ne peut pas faire {#what-an-extension-cannot-do} diff --git a/i18n/fr/pages/advanced/low-level-server.md b/i18n/fr/pages/advanced/low-level-server.md index 4b2d150c..3fc1758b 100644 --- a/i18n/fr/pages/advanced/low-level-server.md +++ b/i18n/fr/pages/advanced/low-level-server.md @@ -1,6 +1,6 @@ --- translation: - sections: [2c79b6338e09b7ac, 7edc43b3fae11314, 1086e77ce561cd7f, a3f71823df5efc31, 9fc7109f72201cae, d50fe7faead8cf68, 7bf25983df655b66, 6330e1f4c6029683, 2f1749c8c133fa1c, 8db7116fc8ddd0ee, ebc33704fbd74262, cd0e9c933350390e] + sections: [2c79b6338e09b7ac, 9d5d10a5f0405d0a, 1086e77ce561cd7f, a3f71823df5efc31, 9fc7109f72201cae, d50fe7faead8cf68, 7bf25983df655b66, 6330e1f4c6029683, 2f1749c8c133fa1c, 8db7116fc8ddd0ee, ebc33704fbd74262, 0fde3bcea081ba3a] tool: 1 --- # Le Server de bas niveau {#the-low-level-server} @@ -36,18 +36,22 @@ Trois choses ont changé, et elles constituent toute l’API de bas niveau : ### Essayer {#try-it} -Pas d’Inspector pour celui-ci : `mcp dev` et `mcp run` n’acceptent qu’un `MCPServer`. Le `Client` en mémoire s’en moque ; il accepte un `Server` de bas niveau exactement comme il accepte un `MCPServer` : +`mcp dev` et `mcp run` n’acceptent qu’un `MCPServer`, vous servez donc celui-ci vous-même. La dernière ligne de `server.py` construit à partir de lui une application ASGI ordinaire, et uvicorn l’exécute : -```python title="main.py" +```console +uvicorn server:app --port 8000 +``` + +Pointez l’Inspector, ou n’importe quel client, vers `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) @@ -64,6 +68,8 @@ Le même texte que celui produit par la version `@mcp.tool()`. Deux différences * `result.structured_content` vaut `None`. Le serveur de haut niveau enveloppe pour vous un `-> str` dans `{"result": ...}` ; ici, personne ne construit ce que vous n’avez pas construit. * `list_tools` renvoie le schéma que **vous** avez saisi, caractère pour caractère. La version de haut niveau avait `"title": "Query"` sur chaque propriété et un `"title": "search_booksArguments"` à la racine : des artefacts de Pydantic. À ce niveau, si quelque chose est sur la liaison, c’est vous qui l’y avez mis. +Dans un test, vous vous passez d’uvicorn et du port : `Client(server)` accepte un `Server` de bas niveau dans le même processus exactement comme il accepte un `MCPServer`, et **[Tests](../get-started/testing.md)** décrit précisément ce schéma. + ## Rien n’est vérifié pour vous {#nothing-is-checked-for-you} `MCPServer` rejette un mauvais argument avant même que votre fonction s’exécute, en validant l’appel par rapport au schéma qu’il a généré (**[Outils](../servers/tools.md)**). @@ -215,4 +221,4 @@ Chacun d’eux correspond à une idée pour laquelle vous avez désormais le voc * `add_request_handler(method, params_type, handler)` sert n’importe quelle méthode. `initialize` est réservée. * Les capacités qu’annonce un `Server` découlent des gestionnaires que vous avez enregistrés. -`Client(server)` a traité les deux serveurs de façon identique parce qu’ils *sont* le même protocole, et c’est tout l’intérêt. La couche suivante vers le bas n’est pas une classe du tout : c’est le **[Middleware](middleware.md)**. +Le client a traité les deux serveurs de façon identique parce qu’ils *sont* le même protocole, et c’est tout l’intérêt. La couche suivante vers le bas n’est pas une classe du tout : c’est le **[Middleware](middleware.md)**. diff --git a/i18n/fr/pages/advanced/pagination.md b/i18n/fr/pages/advanced/pagination.md index 4e0a9eff..2e6c50da 100644 --- a/i18n/fr/pages/advanced/pagination.md +++ b/i18n/fr/pages/advanced/pagination.md @@ -1,6 +1,6 @@ --- translation: - sections: [a9aba7a026c7bd85, ed32bda7ba9ae33a, 7e64cc5646abb91f, 22a0129ee78b3c63, d875373c06d8d2f9] + sections: [a9aba7a026c7bd85, 83e2a08b9d46a398, 9fd8a0aa384b3257, 22a0129ee78b3c63, d875373c06d8d2f9] tool: 1 --- # Pagination {#pagination} @@ -31,9 +31,13 @@ La pagination sert au serveur dont la liste de ressources est en réalité une b ### Essayer {#try-it} -`Client(server)` se connecte à un `Server` de bas niveau en mémoire exactement comme il se connecte à un `MCPServer`. +`mcp run` n’accepte qu’un `MCPServer`, vous servez donc celui-ci vous-même. La dernière ligne de `server.py` construit une application ASGI ordinaire à partir du `Server`, et uvicorn l’exécute : -Appelez `list_resources()` sans argument. Vous obtenez dix ressources, de `book-1` à `book-10`, et `next_cursor` vaut la chaîne `"10"`. +```console +uvicorn server:app --port 8000 +``` + +Pointez n’importe quel client (**[Le client](../client/index.md)**, ou l’Inspector) vers `http://localhost:8000/mcp` et appelez `list_resources()` sans argument. Vous obtenez dix ressources, de `book-1` à `book-10`, et `next_cursor` vaut la chaîne `"10"`. Renvoyez-la avec `list_resources(cursor="10")` : la première ressource est `book-11`, le nouveau `next_cursor` vaut `"20"`. @@ -43,7 +47,7 @@ La dixième page revient avec `next_cursor` à `None`. Terminé. Chaque méthode `list_*` de `Client` (`list_tools`, `list_resources`, `list_resource_templates`, `list_prompts`) accepte un argument nommé `cursor=`. Vider une liste paginée tient en un `while True` : -```python title="client.py" hl_lines="26-32" +```python title="client.py" hl_lines="9-15" --8<-- "docs_src/pagination/tutorial002.py" ``` @@ -51,7 +55,7 @@ Chaque méthode `list_*` de `Client` (`list_tools`, `list_resources`, `list_reso * Étendez la liste **avant** de regarder `next_cursor` : la dernière page contient elle aussi des ressources. * `next_cursor is None` est la sortie. Toute autre valeur repart directement dans `cursor=`, telle quelle. -Lancez son `main()` et il affiche `100 resources` : dix pages de dix, assemblées par une boucle qui n’a jamais su qu’il y avait dix pages. +Pendant qu’uvicorn sert toujours `server.py`, lancez `python client.py` dans un second terminal. Il affiche `100 resources` : dix pages de dix, assemblées par une boucle qui n’a jamais su qu’il y avait dix pages. C’est la même boucle que montre **[Le client](../client/index.md)** pour chaque verbe `list_*`, et elle ne coûte rien face à un serveur qui ne pagine pas : `next_cursor` vaut `None` dès la première réponse et la boucle s’exécute une seule fois. diff --git a/i18n/fr/pages/client/caching.md b/i18n/fr/pages/client/caching.md index a319b784..6ccb5aab 100644 --- a/i18n/fr/pages/client/caching.md +++ b/i18n/fr/pages/client/caching.md @@ -1,6 +1,6 @@ --- translation: - sections: [9e7b9a1710e5aeba, b74ca4c1d2ddddee, fa8714e61bf90c5a, 04db67a886b7271c, 857690fb8f876800] + sections: [9e7b9a1710e5aeba, 66a2e9acc9101d54, d3d25aa802f5145a, 04db67a886b7271c, 857690fb8f876800] tool: 1 --- # Indications de mise en cache {#caching-hints} @@ -30,7 +30,7 @@ Par défaut, chaque résultat indique `ttlMs: 0, cacheScope: "private"` : périm Sur le `Server` bas niveau, les gestionnaires (handlers) construisent leurs résultats à la main, et `ttl_ms` / `cache_scope` sont de simples champs des modèles de résultat. Un gestionnaire qui les définit explicitement l’emporte toujours sur le dictionnaire du constructeur, champ par champ : -```python title="server.py" hl_lines="10 16" +```python title="server.py" hl_lines="11 17" --8<-- "docs_src/caching/tutorial002.py" ``` @@ -44,10 +44,24 @@ Une réserve sur les listes paginées : le protocole exige **le même `cacheScop Sur une session 2026-07-28, `Client` respecte les indications pour vous : il embarque un cache de réponses, activé par défaut. Un résultat qui arrive avec un `ttlMs` est stocké, et un appel identique effectué dans ce TTL est servi depuis le cache, sans aller-retour. Un résultat qui ne porte *aucune* indication n’est pas mis en cache : les résultats sans indication reçoivent `CacheConfig.default_ttl_ms`, dont la valeur par défaut est `0` (périmé immédiatement), si bien qu’un serveur qui ne déclare rien voit exactement le même trafic, appel pour appel, qu’auparavant. -```python title="client.py" hl_lines="33 35 38" +Pour le voir à l’œuvre, servez avec uvicorn le `server.py` de la section précédente (sa dernière ligne construit l’application ASGI). Le gestionnaire affiche une ligne chaque fois qu’il s’exécute réellement : + +```console +uvicorn server:app --port 8000 +``` + +```python title="client.py" hl_lines="20 23 28" --8<-- "docs_src/caching/tutorial003.py" ``` +Lancez `python client.py` depuis un second terminal. Il affiche les indications que portait le premier résultat, le `ttlMs` du gestionnaire à côté du `cacheScope` du dictionnaire : + +```text +1000 public +``` + +Le terminal du serveur raconte la suite : entre les journaux de requêtes d’uvicorn, `tools/list served` apparaît trois fois. + Quatre appels, trois récupérations. Le deuxième appel a trouvé une entrée fraîche et n’a jamais atteint le serveur ; avancer l’horloge (injectée) au-delà du TTL a fait que le troisième récupère à nouveau ; le quatrième a indiqué `cache_mode="refresh"`. Cet argument nommé existe sur les cinq verbes avec cache (`list_tools`, `list_prompts`, `list_resources`, `list_resource_templates`, `read_resource`) : * `"use"` (la valeur par défaut) sert une entrée fraîche s’il y en a une, et stocke le résultat récupéré sinon. @@ -56,7 +70,7 @@ Quatre appels, trois récupérations. Le deuxième appel a trouvé une entrée f Une règle prime sur `"use"` : **les appels portant `meta` atteignent toujours le serveur.** Une requête avec `meta` défini (un jeton de progression, des champs de traçage) attend une requête sur la liaison ; sous `cache_mode="use"`, elle est donc traitée comme `"refresh"` : la lecture du cache est sautée, et le résultat récupéré remplace quand même l’entrée en cache. `"bypass"` et un `"refresh"` explicite se comportent comme d’habitude. -Pour désactiver entièrement la mise en cache, construisez avec `Client(server, cache=None)` : chaque appel redevient un aller-retour, et `cache_mode`, bien que toujours accepté, n’a aucun effet. +Pour désactiver entièrement la mise en cache, passez `cache=None` à la construction du `Client` : chaque appel redevient un aller-retour, et `cache_mode`, bien que toujours accepté, n’a aucun effet. La portée est elle aussi respectée automatiquement : les entrées `"private"` sont indexées sur la *partition* du cache (ci-dessous), tandis que les entrées `"public"` peuvent opter pour un partage plus large. Et **les notifications priment sur le TTL** pour les entrées exactes qu’elles désignent : une notification `list_changed` évince la liste correspondante en cache, et `resources/updated` évince la lecture en cache stockée exactement sous son URI, aussi fraîches soient-elles. Sur une connexion 2026-07-28, ces notifications arrivent sur un flux `subscriptions/listen` que vous ouvrez avec `client.listen(...)`, et l’éviction se termine avant que votre observateur ne voie l’événement ; tous les détails sont dans **[Abonnements](subscriptions.md)**. diff --git a/i18n/fr/pages/client/callbacks.md b/i18n/fr/pages/client/callbacks.md index 551453f2..2235e0f3 100644 --- a/i18n/fr/pages/client/callbacks.md +++ b/i18n/fr/pages/client/callbacks.md @@ -1,6 +1,6 @@ --- translation: - sections: [adf3c545b5be46b6, 916cd3ab1c03f461, e9be7a8d0eb0a456, 565890a636288ecf, 6af7e49db9129ec3, 06b0238c174186af, 90c6043be435fcb0] + sections: [adf3c545b5be46b6, 916cd3ab1c03f461, 32ef568335dd95a7, 565890a636288ecf, 6af7e49db9129ec3, 06b0238c174186af, 0abc5ea5cb7ff6b3] tool: 1 --- # Fonctions de rappel du client {#client-callbacks} @@ -63,7 +63,7 @@ Un `tools/call` de votre part, un `elicitation/create` en retour du serveur, auq `mode="legacy"` dans l’appel `Client(...)` fait un vrai travail. Par défaut, `Client(...)` négocie le chemin moderne du protocole, et ce chemin n’a pas de canal de retour (back-channel) pour les requêtes du serveur vers le client : `ctx.elicit` échoue avant même que votre fonction de rappel ne s’exécute. Ce n’est pas le transport qui en décide ; c’est le - protocole négocié, en mémoire comme via une URL. Fixez `mode="legacy"` dès que votre client doit + protocole négocié. Fixez `mode="legacy"` dès que votre client doit répondre à l’une d’elles ; tous les tests derrière cette page le font. Tous les détails sont dans **[Versions du protocole](../protocol-versions.md)**. Sur une session 2026-07-28, la fonction de rappel n’est pas morte, elle est alimentée autrement : quand un outil renvoie un @@ -151,4 +151,4 @@ Deux de plus. Aucune ne déclare quoi que ce soit. * `sampling_callback` et `list_roots_callback` fonctionnent de la même manière mais servent des fonctionnalités obsolètes ; les serveurs modernes utilisent à la place les requêtes à plusieurs allers-retours. * `logging_callback` et `message_handler` reçoivent des notifications. Ils ne déclarent rien. -Le premier argument de `Client(...)` est un objet transport. **[Transports client](transports.md)** couvre tous les types. +Le premier argument de `Client(...)` choisit le transport. **[Transports client](transports.md)** couvre tous les types. diff --git a/i18n/fr/pages/client/index.md b/i18n/fr/pages/client/index.md index 3a85f884..291933a4 100644 --- a/i18n/fr/pages/client/index.md +++ b/i18n/fr/pages/client/index.md @@ -1,6 +1,6 @@ --- translation: - sections: [ebef1e7a0df854f4, 8355cfaf1f76c9d5, 8e79141fc2985342, 46bdb07c7537e8a5, 80ce41579825a6fa, 5f0fa90494de8f65, 83d10514eaa62fa5, 9190555aa39a5d28, 84a4c9d8bf14dddb, 927d71cf40b58c30] + sections: [ebef1e7a0df854f4, 7e16449f66e7dfd6, eeb0682f7d2a1079, 5713f0196a34e6e7, 0e844597859e4248, 3a97d9195ddcc92e, 1da08c483e59c141, 84702cc6e0a1fd42, 8dee7a31c86ffc5c, 83a5bce168ef23d7] tool: 1 --- # Le client {#the-client} @@ -11,13 +11,23 @@ C’est un seul objet avec un seul cycle de vie : vous le construisez, vous entr ## Votre premier client {#your-first-client} -```python title="client.py" hl_lines="14-18" +Un client a besoin d’un serveur avec qui dialoguer. Ce serveur Bookshop est celui auquel se connecte chaque extrait de cette page. Enregistrez-le sous le nom `server.py` et laissez-le tourner en HTTP : + +```python title="server.py" --8<-- "docs_src/client/tutorial001.py" ``` -Le serveur en haut n’est là que pour vous donner quelque chose à quoi vous connecter. Le client, ce sont les cinq lignes surlignées. +```console +uv run mcp run server.py --transport streamable-http +``` -* `Client(mcp)` reçoit **l’objet serveur lui-même**. C’est le transport en mémoire : pas de sous-processus, pas de port, pas de HTTP. C’est ainsi que se connectent tous les exemples de cette page, et tous les tests que vous écrivez. +Cela le sert à l’adresse `http://localhost:8000/mcp`. Le client est un programme à part. Enregistrez-le sous le nom `client.py` et lancez `python client.py` dans un second terminal : + +```python title="client.py" hl_lines="7-11" +--8<-- "docs_src/client/tutorial001_client.py" +``` + +* `Client("http://localhost:8000/mcp")` reçoit une **URL**, il se connecte donc en Streamable HTTP au serveur que vous venez de démarrer. * `async with` est le **cycle de vie**. Y entrer connecte et négocie ; en sortir déconnecte. Il n’y a pas de paire `connect()` / `close()`, et un `Client` ne peut pas être réutilisé une fois le bloc terminé. * À l’intérieur du bloc, les informations de connexion sont déjà là, sous forme de simples propriétés. @@ -25,10 +35,10 @@ Le serveur en haut n’est là que pour vous donner quelque chose à quoi vous c `Client` prend un seul argument positionnel et déduit le transport de son type : -* Une instance de `MCPServer` (ou du `Server` bas niveau) : connexion **dans le processus**. -* Une chaîne d’URL (`Client("http://localhost:8000/mcp")`) : Streamable HTTP, la voie de production. -* Un `StdioServerParameters` : la commande à lancer en **sous-processus**, avec laquelle le client dialogue via son stdin et son stdout. +* Une chaîne d’URL (`Client("http://localhost:8000/mcp")`) : Streamable HTTP, le transport derrière lequel vous déployez. +* Un `StdioServerParameters` : la commande à lancer comme **sous-processus** local, avec laquelle le client dialogue via son stdin et son stdout. * Un **transport** : tout ce sur quoi vous pouvez faire `async with ... as (read, write)`, comme `streamable_http_client(url, http_client=...)` autour de votre propre client HTTP. +* Une instance de `MCPServer` (ou du `Server` bas niveau) : connexion **dans le processus**, sans sous-processus ni port. Celle-ci sert aux tests, et **[Tests](../get-started/testing.md)** s’appuie dessus. Tout le reste de cette page est identique pour les quatre. Les en-têtes, les sous-processus, les délais d’expiration et le protocole `Transport` ont leur propre page : **[Transports côté client](transports.md)**. @@ -49,11 +59,11 @@ Vous n’avez jamais choisi de version du protocole. Par défaut, le `Client` so ## Lister les outils {#listing-tools} -```python title="client.py" hl_lines="15-20" +```python title="client.py" hl_lines="8-13" --8<-- "docs_src/client/tutorial002.py" ``` -`list_tools()` renvoie un `ListToolsResult` ; les outils sont dans `.tools`. Chacun est la définition complète qu’un hôte transmettrait à un modèle : +`list_tools()` renvoie un `ListToolsResult` ; les outils sont dans `.tools`. Chacun est la définition complète qu’un hôte transmettrait à un modèle. Voici le premier : ```python tool.name # 'search_books' @@ -77,6 +87,8 @@ et `tool.input_schema` est le JSON Schema que le serveur a dérivé des annotati Ce schéma est tout ce dont une interface a besoin pour afficher un formulaire d’arguments, et tout ce dont un modèle a besoin pour produire des arguments valides. +Le second outil, `lookup_book`, a été enregistré sans `title=`, donc son `tool.title` vaut `None`. + !!! tip `title` est facultatif, donc une interface qui présente des outils à un humain doit choisir : le `title` s’il existe, le `name` sinon. `from mcp.shared.metadata_utils import get_display_name` fait exactement cela, @@ -86,7 +98,7 @@ Ce schéma est tout ce dont une interface a besoin pour afficher un formulaire d `call_tool(name, arguments)` exécute l’outil et vous renvoie un `CallToolResult`. -```python title="client.py" hl_lines="27-34" +```python title="client.py" hl_lines="9-16" --8<-- "docs_src/client/tutorial003.py" ``` @@ -142,7 +154,7 @@ Un outil qui lève une exception ne lève **rien** dans votre client. Il revient Les verbes des ressources vont par paires : deux façons de lister, une façon de lire. -```python title="client.py" hl_lines="22-31" +```python title="client.py" hl_lines="9-18" --8<-- "docs_src/client/tutorial004.py" ``` @@ -156,7 +168,7 @@ Un client peut aussi être prévenu quand une ressource change. Sur les connexio ## Prompts {#prompts} -```python title="client.py" hl_lines="15-20" +```python title="client.py" hl_lines="8-13" --8<-- "docs_src/client/tutorial005.py" ``` @@ -181,7 +193,7 @@ Un hôte transmet ces messages tels quels au modèle. C’est toute la fonctionn Un serveur doté d’un gestionnaire (handler) de complétion peut compléter automatiquement les arguments des prompts et des modèles de ressource au fil de la saisie de l’utilisateur. -```python title="client.py" hl_lines="27-31" +```python title="client.py" hl_lines="9-13" --8<-- "docs_src/client/tutorial006.py" ``` @@ -194,21 +206,21 @@ La réponse se trouve dans `result.completion.values`. Tapez `"p"` et le serveur Chaque méthode `list_*` accepte un argument nommé `cursor=` et chaque résultat porte un `next_cursor`. Quand `next_cursor` vaut `None`, vous avez tout. -```python title="client.py" hl_lines="22-30" +```python title="client.py" hl_lines="7-15" --8<-- "docs_src/client/tutorial007.py" ``` -Cette boucle est correcte face à n’importe quel serveur. `MCPServer` renvoie tout en une seule page, donc `next_cursor` vaut `None` et la boucle s’exécute une fois, ce qui explique que la plupart du code ne l’écrive jamais. Les serveurs qui paginent réellement, et les règles auxquelles obéissent les curseurs, sont dans **[Pagination](../advanced/pagination.md)**. +La fonction `list_all_tools` est correcte face à n’importe quel serveur. `MCPServer` renvoie tout en une seule page, donc `next_cursor` vaut `None` et la boucle s’exécute une fois, ce qui explique que la plupart du code ne l’écrive jamais. Les serveurs qui paginent réellement, et les règles auxquelles obéissent les curseurs, sont dans **[Pagination](../advanced/pagination.md)**. ## Dans les tests {#in-tests} -`Client(mcp)`, sans processus ni port, est déjà un banc de test pour votre serveur. +Chaque `client.py` de cette page a atteint `server.py` en HTTP. Dans un test, vous vous passez du réseau et donnez à `Client` l’objet serveur lui-même : `from server import mcp`, puis `Client(mcp)`. Pas de processus, pas de port, et chaque méthode ci-dessus fonctionne de la même façon. -Il existe un drapeau du constructeur conçu pour cela : `Client(mcp, raise_exceptions=True)`. Il n’a d’effet que sur les connexions en mémoire, et **[Tests](../get-started/testing.md)** est la page qui l’explique et construit tout le modèle autour de lui. +Il existe un drapeau du constructeur conçu pour cela : `Client(mcp, raise_exceptions=True)`. Il n’a d’effet que sur les connexions dans le processus, et **[Tests](../get-started/testing.md)** est la page qui l’explique et construit tout le modèle autour de lui. ## Récapitulatif {#recap} -* `Client(x)` se connecte en mémoire à un objet serveur, en Streamable HTTP à une chaîne d’URL, et à tout le reste via un transport. +* `Client(x)` se connecte en Streamable HTTP à une chaîne d’URL, lance un sous-processus pour un `StdioServerParameters`, entre directement dans un transport et, dans les tests, prend l’objet serveur lui-même. * `async with` est tout le cycle de vie. À l’intérieur, `server_capabilities` et `protocol_version` sont déjà renseignés ; `server_info` et `instructions` le sont aussi lorsque le serveur les fournit. * `list_tools()` vous donne le `name`, le `title`, la `description` et le `input_schema` de chaque outil. * `call_tool()` renvoie `content` pour le modèle, `structured_content` pour votre code, et `is_error`. Un outil qui lève une exception est un résultat, pas une exception. diff --git a/i18n/fr/pages/client/oauth-clients.md b/i18n/fr/pages/client/oauth-clients.md index 346d2f49..e89a259e 100644 --- a/i18n/fr/pages/client/oauth-clients.md +++ b/i18n/fr/pages/client/oauth-clients.md @@ -1,6 +1,6 @@ --- translation: - sections: [c6899d3892bd9fa0, 79372cff3cc48a88, 63878d29e87c3e73, 13175843d3588af4, e7e2b9fd516f77de, 758f06399b513c1f, a05d7278487d610b] + sections: [c6899d3892bd9fa0, 79372cff3cc48a88, c2dae1ebe2ebd543, 13175843d3588af4, df06056fb16b3846, 758f06399b513c1f, a05d7278487d610b] tool: 1 --- # Clients OAuth {#oauth-clients} @@ -81,18 +81,20 @@ Regardez `main()`. Le fournisseur va sur le **client httpx2**, le client httpx2 La première fois que `Client` envoie une requête, le serveur répond `401`. Le fournisseur prend le relais : -1. **Découverte.** Il lit l’en-tête `WWW-Authenticate`, récupère les Protected Resource Metadata du serveur depuis `/.well-known/oauth-protected-resource`, apprend quel serveur d’autorisation protège cette ressource, et récupère les métadonnées de *ce* serveur-là. +1. **Découverte.** Il lit l’en-tête `WWW-Authenticate`, récupère les Protected Resource Metadata du serveur depuis `/.well-known/oauth-protected-resource`, apprend quel serveur d’autorisation protège cette ressource, et récupère les métadonnées de *ce* serveur-là. (Un serveur plus ancien qui ne publie aucune métadonnée de ressource se voit demander, à la place, les métadonnées de serveur d’autorisation à sa propre origine.) Dans les deux cas, les métadonnées doivent désigner, comme `issuer`, le serveur pour lequel elles ont été récupérées ; tout autre cas est refusé. 2. **Enregistrement.** Rien dans le stockage ? Il vous enregistre dynamiquement avec votre `OAuthClientMetadata` et stocke le résultat. 3. **Autorisation.** Il génère la paire PKCE et un `state`, construit l’URL d’autorisation, attend votre `redirect_handler`, puis attend votre `callback_handler` pour obtenir le code. 4. **Échange.** Il échange le code contre un `OAuthToken`, le stocke, et rejoue votre requête d’origine avec `Authorization: Bearer ...`. Après cela, il se fait discret. Les jetons sortent du stockage, un jeton d’accès expiré est actualisé avec le jeton d’actualisation, et ce n’est que lorsque rien de tout cela ne fonctionne qu’il relance le flux. +Une règle de transport s’applique à toutes ces requêtes : comme la requête MCP à l’intérieur de laquelle elles s’exécutent, elles ne suivent une redirection que si celle-ci reste sur la même origine et conserve la méthode (un 307/308 pour une barre oblique finale, par exemple), et traitent toute autre redirection comme si cette URL ne répondait pas. + Vous n’avez rien écrit de tout cela. Il reste deux arguments nommés (`client_metadata_url` et `validate_resource_url`), et ce fichier n’a besoin d’aucun des deux. `client_metadata_url` est celui qui mérite d’être connu ; il a sa propre section plus bas. ### Essayer {#try-it} -La plupart des exemples de cette documentation se vérifient avec un `Client(server)` en mémoire. Pas celui-ci : tout l’intérêt du flux est un `401` HTTP, et il n’y a pas de HTTP entre un client en mémoire et son serveur. +Le `Client(server)` en mémoire qu’utilisent vos tests n’est d’aucune aide ici : tout l’intérêt du flux est un `401` HTTP, et il n’y a pas de HTTP entre un client en mémoire et son serveur. Le dépôt fournit la version réelle. `examples/servers/simple-auth/` exécute un serveur d’autorisation autonome et un serveur MCP protégé ; `examples/clients/simple-auth-client/` est le client de cette page devenu une petite CLI. Son README donne les deux commandes : démarrez les serveurs, lancez le client contre eux, et vous voyez défiler les quatre étapes. @@ -110,13 +112,14 @@ Une tâche nocturne, une étape de CI, un autre service. Il n’y a pas de navig `ClientCredentialsOAuthProvider` est le même `httpx2.Auth`, l’humain en moins : -```python title="client.py" hl_lines="4 27-33" +```python title="client.py" hl_lines="4 27-34" --8<-- "docs_src/oauth_clients/tutorial002.py" ``` Ce qui a changé : * Aucun `OAuthClientMetadata`, aucun gestionnaire. Vous passez `client_id` et `client_secret` ; le fournisseur construit autour d’eux un enregistrement `client_credentials` minimal et saute entièrement l’enregistrement dynamique. +* `issuer` désigne le serveur d’autorisation qui a émis ces identifiants ; utilisez la valeur `issuer` que renvoie son document `/.well-known/oauth-authorization-server`. La découverte se déroule toujours comme ci-dessus, mais les requêtes de jeton ne sont jamais construites qu’à partir des métadonnées de *cet* émetteur-là ; si le serveur MCP pointe ailleurs, le flux s’arrête alors avec une `OAuthFlowError`. L’omettre est obsolète et il devient obligatoire en 3.0 (voir **[Fonctionnalités obsolètes](../deprecated.md#deprecated-sdk-helpers)**) ; d’ici là, le fournisseur émet un avertissement et utilise le serveur d’autorisation que la découverte trouve, quel qu’il soit. * `scope` est une chaîne séparée par des espaces, le format qu’OAuth utilise sur la liaison. * Tout ce qui se trouve en aval est identique : le même `TokenStorage`, le même `httpx2.AsyncClient(auth=...)`, le même `streamable_http_client`. @@ -129,7 +132,7 @@ Par défaut, le secret voyage en authentification HTTP Basic sur la requête de Un fournisseur de plus se trouve dans `mcp.client.auth.extensions.client_credentials` : **`PrivateKeyJWTOAuthProvider`**, pour les clients qui s’authentifient avec un JWT plutôt qu’avec un secret partagé (`private_key_jwt`, la variante à paire de clés et identité de charge de travail). Il suit - le même schéma : construisez-en un, placez-le sur `auth=`. Le même module fournit + le même schéma : construisez-en un (il accepte le même `issuer` optionnel), placez-le sur `auth=`. Le même module fournit `SignedJWTParameters` et `static_assertion_provider`, deux utilitaires qui construisent son assertion. Il existe une autre situation sans humain : le client appartient à une entreprise dont le fournisseur d’identité, et non l’utilisateur, décide quels serveurs MCP il peut atteindre. C’est un type d’octroi différent, avec son propre modèle de confiance et sa propre page, **[Assertion d’identité](identity-assertion.md)**. diff --git a/i18n/fr/pages/client/session-groups.md b/i18n/fr/pages/client/session-groups.md index 6ab8e19f..0f518986 100644 --- a/i18n/fr/pages/client/session-groups.md +++ b/i18n/fr/pages/client/session-groups.md @@ -1,6 +1,6 @@ --- translation: - sections: [09c857a25a9dc37a, 43bc6a76a243a50e, 0a716022a88768df, 4b7f78042bfcfff7, c112662e61b03315, 58974ba1f489a8b4, d18adbdbb835ea73] + sections: [09c857a25a9dc37a, 43bc6a76a243a50e, 0a716022a88768df, 4b7f78042bfcfff7, c112662e61b03315, 58974ba1f489a8b4, ed4d17e894864056] tool: 1 --- # Groupes de sessions {#session-groups} @@ -78,7 +78,7 @@ Si vous détenez déjà une `ClientSession` connectée (`Client.session` en est ## Récapitulatif {#recap} * `ClientSessionGroup` détient de nombreuses connexions serveur et fusionne leurs outils, ressources et prompts en un `dict` chacun. -* `connect_to_server(params)` par serveur. Il prend des paramètres de transport, jamais l’objet serveur ni l’URL que prend un `Client`. +* `connect_to_server(params)` par serveur. Il prend des paramètres de transport, jamais l’URL ni le `Transport` que prend un `Client`. * `group.call_tool(name, arguments)` achemine l’appel vers le serveur propriétaire à votre place. * Les noms doivent être uniques dans tout le groupe ; deux serveurs dotés d’un outil `search` ne peuvent pas coexister tels quels. * `component_name_hook=` réécrit chaque nom enregistré. La clé du dict change, pas le nom sur la liaison. diff --git a/i18n/fr/pages/client/transports.md b/i18n/fr/pages/client/transports.md index dd644ff6..e7aea554 100644 --- a/i18n/fr/pages/client/transports.md +++ b/i18n/fr/pages/client/transports.md @@ -1,6 +1,6 @@ --- translation: - sections: [9cac816674181eb0, 0700f337babcd4dd, 2bde0dd58cdf00f5, 40b4916d82eaf1d4, 3d0832f39b0d7059, dfa4446556badef0, 5bd93be2ab2ecb9c] + sections: [9cac816674181eb0, 7c157764133fea1f, 40b4916d82eaf1d4, 10d151f2cc75317f, 3d0832f39b0d7059, 92742ba36533633d, 0aeca6145e7bd302] tool: 1 --- # Transports côté client {#client-transports} @@ -11,30 +11,15 @@ Vous n’en configurez jamais un séparément. `Client` prend un seul argument p Le côté *serveur* de chacun (ce que fait `mcp.run()` et ce que vous déployez) est traité dans **[Exécuter votre serveur](../run/index.md)**. -## En mémoire {#in-memory} - -Passez l’objet serveur lui-même : - -```python title="client.py" hl_lines="14" ---8<-- "docs_src/client_transports/tutorial001.py" -``` - -Pas de sous-processus, pas de port, aucun octet sur une liaison. Le client et le serveur sont deux objets dans le même processus, et l’appel passe tout de même par la véritable couche protocolaire : `search_books` est listé, validé et invoqué exactement comme il le serait via HTTP. - -Cela en fait deux choses à la fois : - -* **Un banc de test.** Chaque exemple de cette documentation est exécuté de cette façon, et la page **[Tests](../get-started/testing.md)** construit tout son modèle autour de lui. -* **Une API d’intégration.** Une application qui construit le serveur n’a pas besoin d’un saut réseau pour appeler ses outils. - ## Streamable HTTP {#streamable-http} -Passez une URL sous forme de chaîne et vous obtenez **Streamable HTTP**, le transport derrière lequel vous déployez : +Passez une URL sous forme de chaîne et vous obtenez **Streamable HTTP**, le transport derrière lequel vous déployez et le premier vers lequel vous tourner : ```python title="client.py" hl_lines="5" --8<-- "docs_src/client_transports/tutorial002.py" ``` -C’est tout le client de production. `Client` enveloppe l’URL dans `streamable_http_client(...)` pour vous, par-dessus un `httpx2.AsyncClient` configuré comme MCP l’exige : `follow_redirects=True`, un délai d’expiration de 30 secondes pour connect/write/pool, et un délai de lecture de 300 secondes parce que le serveur peut garder un flux de réponse ouvert. +C’est tout le client de production. `Client` enveloppe l’URL dans `streamable_http_client(...)` pour vous, par-dessus un `httpx2.AsyncClient` configuré comme MCP l’exige : un délai d’expiration de 30 secondes pour connect/write/pool, et un délai de lecture de 300 secondes parce que le serveur peut garder un flux de réponse ouvert. !!! check Un `Client` que vous venez de construire n’est **pas** connecté. La construction ne fait que choisir le transport ; @@ -50,7 +35,7 @@ C’est tout le client de production. `Client` enveloppe l’URL dans `streamabl Dès que vous avez besoin d’un en-tête `Authorization`, d’un cookie, d’un proxy, de mTLS ou d’un délai d’expiration différent, construisez le `httpx2.AsyncClient` vous-même et passez-le à `streamable_http_client` : -```python title="client.py" hl_lines="8-14" +```python title="client.py" hl_lines="8-13" --8<-- "docs_src/client_transports/tutorial003.py" ``` @@ -80,9 +65,30 @@ ou passez un `verify=ssl_context` explicite à votre `httpx2.AsyncClient` !!! info `httpx2` conserve l’API familière de `httpx` ; si vous connaissez `httpx`, vous savez déjà comment gérer ici l’authentification, les proxys, les hooks d’événements, les nouvelles tentatives et les limites de connexions. Le SDK n’ajoute rien par-dessus et ne retire - rien. C’est aussi là qu’OAuth se branche : + rien, à l’exception de la [gestion des redirections](#redirects). C’est aussi là qu’OAuth se branche : `httpx2.AsyncClient(auth=OAuthClientProvider(...))`. Tout ce flux est décrit dans **[Clients OAuth](oauth-clients.md)**. +### Redirections {#redirects} + +Le transport se connecte à l’URL que vous lui avez donnée, et à cette origine uniquement. + +* Une redirection `307`/`308` qui reste sur le même schéma, le même hôte et le même port est suivie, tout comme `http://` → `https://` sur le même hôte. Cela couvre la redirection habituelle de barre oblique finale `/mcp` → `/mcp/`. +* Une redirection vers n’importe quel autre endroit n’est **pas** suivie. L’appel échoue avec : + + ```text + MCPError: Redirect to https://other.example.com/mcp not followed; use that URL as the endpoint if it is the intended server + ``` + + Si cette URL est bien le serveur que vous visiez, mettez-la dans votre configuration. Sinon, le serveur ou un proxy placé devant lui est mal configuré. + +Cela vaut pour tout `httpx2.AsyncClient` que vous passez : son réglage `follow_redirects` n’est pas consulté pour les requêtes MCP, dans un sens comme dans l’autre. Les fournisseurs OAuth du SDK appliquent la même règle à leurs propres requêtes. + +!!! tip + `Redirect to http://… not followed: it would downgrade this HTTPS endpoint to plain HTTP` signifie que le + serveur se trouve derrière un proxy à terminaison TLS dont il ignore l’existence et qu’il émet des redirections en `http://`. + Cela se corrige côté serveur (**[Déployer et passer à l’échelle](../run/deploy.md#behind-a-tls-terminating-proxy)**), + ou en utilisant l’URL `https://…/` exacte que le message suggère. + ## stdio {#stdio} Un serveur **stdio** est un sous-processus. Le client le lance, écrit du JSON-RPC sur son stdin et lit du JSON-RPC depuis son stdout. C’est ainsi qu’un hôte de bureau exécute un serveur sur votre machine : un hôte *est* ce code plus une interface utilisateur, et **[Se connecter à un véritable hôte](../get-started/real-host.md)** montre la même relation vue du côté de l’hôte, sous forme de fichier de configuration. @@ -105,6 +111,18 @@ Le stderr du processus enfant va vers le vôtre. Pour l’envoyer ailleurs, cons Un serveur qui a besoin d’une clé d’API ne l’y trouvera pas. Passez-la explicitement avec `env=` ; ces variables sont fusionnées par-dessus la liste d’autorisation. C’est ce que fait `BOOKSHOP_API_KEY` ci-dessus. +## En mémoire {#in-memory} + +Dans un test, il n’y a rien à déployer ni rien à lancer. Passez l’objet serveur lui-même : + +```python hl_lines="14" +--8<-- "docs_src/client_transports/tutorial001.py" +``` + +Pas de sous-processus, pas de port, aucun octet sur une liaison. Le client et le serveur sont deux objets dans le même processus, et l’appel passe tout de même par la véritable couche protocolaire : `search_books` est listé, validé et invoqué exactement comme il le serait via HTTP. La page **[Tests](../get-started/testing.md)** construit tout son modèle autour de lui. + +La même forme sert aussi d’API d’intégration : une application qui construit elle-même le serveur peut appeler ses outils sans saut réseau. + ## SSE {#sse} `sse_client(url)`, du module `mcp.client.sse`, est le transport HTTP que Streamable HTTP a remplacé. Enveloppez-le de la même manière, `Client(sse_client("http://localhost:8000/sse"))`, pour dialoguer avec un serveur qui le parle encore, et ne construisez rien de nouveau dessus. @@ -113,15 +131,16 @@ Le stderr du processus enfant va vers le vôtre. Pour l’envoyer ailleurs, cons Pour `Client`, tout ce qui précède est une seule et même chose. -Un **transport** est n’importe quel gestionnaire de contexte asynchrone qui produit une paire `(read, write)` de flux de messages : formellement, le protocole `Transport` de `mcp.client`. `Client` résout son argument selon son type : un objet serveur se connecte dans le processus, une `str` devient `streamable_http_client(url)`, un `StdioServerParameters` devient `stdio_client(params)`, et tout le reste est ouvert directement comme transport. C’est cette dernière règle qui explique pourquoi `stdio_client(...)`, `streamable_http_client(...)` et `sse_client(...)` s’insèrent tous au même emplacement, et pourquoi vous pouvez écrire le vôtre. +Un **transport** est n’importe quel gestionnaire de contexte asynchrone qui produit une paire `(read, write)` de flux de messages : formellement, le protocole `Transport` de `mcp.client`. `Client` résout son argument selon son type : une `str` devient `streamable_http_client(url)`, un `StdioServerParameters` devient `stdio_client(params)`, un objet serveur se connecte dans le processus, et tout le reste est ouvert directement comme transport. C’est cette dernière règle qui explique pourquoi `stdio_client(...)`, `streamable_http_client(...)` et `sse_client(...)` s’insèrent tous au même emplacement, et pourquoi vous pouvez écrire le vôtre. ## Récapitulatif {#recap} -* `Client(mcp)` (l’objet serveur) se connecte en mémoire. Utilisez-le pour les tests et pour l’intégration. * `Client("http://.../mcp")` (une URL) se connecte via Streamable HTTP, le transport de production. * Les en-têtes, l’authentification, les proxys et les délais d’expiration vont sur un `httpx2.AsyncClient` que vous passez à `streamable_http_client(url, http_client=...)`. Il n’y a pas de mot-clé `headers=`. +* Les redirections ne sont suivies qu’à l’intérieur de l’origine de l’URL (une redirection `307`/`308` de barre oblique finale), plus `http`→`https` sur le même hôte. Tout le reste échoue avec `Redirect to … not followed` ; configurez l’URL finale. * stdio s’écrit `Client(StdioServerParameters(...))`. Ne l’enveloppez vous-même dans `stdio_client(...)` que pour rediriger le stderr du processus enfant. * Le sous-processus reçoit un environnement sous liste d’autorisation, pas le vôtre ; `env=` s’y ajoute. +* `Client(mcp)` (l’objet serveur) se connecte en mémoire. Utilisez-le dans les tests, ou pour intégrer un serveur dans l’application qui l’a construit. * Un transport est tout ce sur quoi vous pouvez faire `async with x as (read, write)`. `Client` transmet directement à ce protocole tout ce qui n’est ni un objet serveur, ni une URL, ni un `StdioServerParameters`. * Construire un `Client` choisit le transport. `async with` l’ouvre. diff --git a/i18n/fr/pages/deprecated.md b/i18n/fr/pages/deprecated.md index 9a25f916..58facd98 100644 --- a/i18n/fr/pages/deprecated.md +++ b/i18n/fr/pages/deprecated.md @@ -1,11 +1,11 @@ --- translation: - sections: [490237e61c3a7a44, 01262a123ad9501d, 429db5b574a2ac08, e2d0d273fbd2d74b, 64ab0331e868f3d4, 6c8878ce2d1f6d56, 4068f23e371bf0b3, eaef75b8725bc931] + sections: [4c1dea378b2b1bf7, 01262a123ad9501d, 429db5b574a2ac08, e2d0d273fbd2d74b, 64ab0331e868f3d4, 6c8878ce2d1f6d56, c3d1099701156881, e185a1b8e53669f6] tool: 1 --- # Fonctionnalités obsolètes {#deprecated-features} -La spécification 2026-07-28 retire cinq éléments. Le SDK les implémente toujours tous, et chacun d’eux porte désormais un **avertissement d’obsolescence**. Un utilitaire du SDK est obsolète pour des raisons qui lui sont propres ; il figure [à la fin](#deprecated-sdk-helpers). +La spécification 2026-07-28 retire cinq éléments. Le SDK les implémente toujours tous, et chacun d’eux porte désormais un **avertissement d’obsolescence**. Quelques obsolescences propres au SDK existent pour des raisons qui leur sont propres ; elles figurent [à la fin](#deprecated-sdk-helpers). Le tableau ci-dessous nomme chaque fonctionnalité obsolète, la raison de sa disparition et le remplacement sur lequel vous appuyer. @@ -138,11 +138,13 @@ C’est toute l’API. Il n’y a pas d’interrupteur par méthode, et vous n ## Utilitaires du SDK obsolètes {#deprecated-sdk-helpers} -Il ne s’agit pas de changements de la spécification, seulement de rouages internes du SDK qui ont un meilleur remplacement. Ils avertissent avec le même `MCPDeprecationWarning` et seront supprimés dans la version 3.0. +Il ne s’agit pas de changements de la spécification, seulement d’usages du SDK qui ont un meilleur remplacement. Ils avertissent avec le même `MCPDeprecationWarning`, et la version 3.0 supprime l’ancienne forme. | Obsolète | Ce que vous faites à la place | |---|---| | `FuncMetadata.call_fn_with_arg_validation()` | `FuncMetadata.validate_arguments()` puis `FuncMetadata.call_fn()`. Seul du code qui pilote directement `FuncMetadata` (une sous-classe personnalisée de `Tool`, par exemple) l’a jamais appelée. | +| `AuthSettings(resource_server_url=...)` sans `validate_token_resource=` | Définissez-le : `True` fait refuser au serveur les jetons porteurs que votre vérificateur ne signale pas comme émis pour `resource_server_url`, `False` indique que votre vérificateur contrôle lui-même l’audience du jeton (voir **[Autorisation](run/authorization.md#a-token-verifier)**). Non défini, il se comporte comme `False` ; la version 3.0 fait de `True` la valeur par défaut dès que `resource_server_url` est défini. | +| `ClientCredentialsOAuthProvider(...)` ou `PrivateKeyJWTOAuthProvider(...)` sans `issuer=` | Passez `issuer=` pour nommer le serveur d’autorisation qui a émis les identifiants (voir **[Écrire des clients OAuth](client/oauth-clients.md#machine-to-machine)**). Sans lui, c’est le serveur MCP qui décide quel serveur d’autorisation les reçoit ; la version 3.0 rend ce paramètre nommé obligatoire. | ## Récapitulatif {#recap} @@ -151,7 +153,7 @@ Il ne s’agit pas de changements de la spécification, seulement de rouages int * L’obsolescence est indicative : aucun changement sur la liaison, tout continue de fonctionner sur les sessions d’avant 2026, et vous obtenez un `MCPDeprecationWarning` visible (un `UserWarning`, donc actif par défaut). * L’échantillonnage et les racines ont en plus besoin d’un canal de retour qu’une session 2026-07-28 n’a pas. Sur une connexion moderne, ils avertissent puis lèvent une exception. * `warnings.filterwarnings("ignore", category=MCPDeprecationWarning)` fait taire toute la catégorie ; `"error::mcp.MCPDeprecationWarning"` dans pytest la transforme en échec de test. -* Un utilitaire du SDK, `FuncMetadata.call_fn_with_arg_validation()`, est obsolète séparément, pour suppression dans la version 3.0. +* Les [obsolescences propres au SDK](#deprecated-sdk-helpers) suivent la même règle : elles avertissent dès maintenant, et la version 3.0 abandonne l’ancienne forme. * Aucun nouveau code ne devrait s’appuyer sur l’une de ces fonctionnalités. Toutes les autres pages de cette documentation enseignent l’API actuelle. diff --git a/i18n/fr/pages/get-started/first-steps.md b/i18n/fr/pages/get-started/first-steps.md index ac30ba9a..c5873f0c 100644 --- a/i18n/fr/pages/get-started/first-steps.md +++ b/i18n/fr/pages/get-started/first-steps.md @@ -1,6 +1,6 @@ --- translation: - sections: [0d6c05bcbf836bf3, 59a7b14eeefc68c1, 7114d8d6daba203f, e8bbb56a98ba7bc9, 5138010f6159901c, f78da7c7c363d4c6, 220a939cab348686] + sections: [0d6c05bcbf836bf3, 9a78b5f6b44b18ab, 7114d8d6daba203f, e8bbb56a98ba7bc9, bfd2fd1153e71dac, 1615a994ef071fdd, 65c599fae991f245] tool: 1 --- # Premiers pas {#first-steps} @@ -17,7 +17,7 @@ Trois mots que vous verrez sur chaque page à partir d’ici : * Un **client** vit à l’intérieur de l’hôte et parle MCP. L’hôte exécute un client par serveur auquel il est connecté. * Un **serveur** est ce que vous construisez avec ce SDK. Il expose des choses aux clients. Il ne parle jamais directement au modèle. -Vous écrivez le serveur. Les hôtes sont le produit de quelqu’un d’autre. Le SDK vous fournit aussi un `Client`. Vous l’utiliserez pour tester vos serveurs, et il apparaît plus loin sur cette page. +Vous écrivez le serveur. Les hôtes sont le produit de quelqu’un d’autre. Le SDK vous fournit aussi un `Client`, la même classe qu’un hôte utiliserait pour joindre un serveur par son URL ou le lancer comme sous-processus. Il apparaît plus loin sur cette page, et c’est aussi avec lui que vous testerez vos serveurs. ## Les trois primitives {#the-three-primitives} @@ -83,22 +83,20 @@ Vous avez vu trois onglets dans l’Inspector. Comment savait-il qu’il y en av Lorsqu’un client se connecte, le serveur déclare ses **capacités** (capabilities) : les familles de requêtes auxquelles il répondra. Le client utilise cette déclaration pour décider de ce qu’il peut même demander. Vous ne l’avez jamais écrite ; `MCPServer` la déclare pour vous. -Regardez par vous-même. Le `Client` du SDK accepte directement l’objet serveur et s’y connecte **en mémoire** (ni sous-processus, ni port) : +Regardez par vous-même. Laissez `server.py` tourner en HTTP dans un terminal : -```python -import asyncio +```console +uv run mcp run server.py --transport streamable-http +``` -from mcp import Client +et pointez un client dessus depuis un autre : -from server import mcp +```python title="client.py" hl_lines="7-8" +--8<-- "docs_src/first_steps/tutorial001_client.py" +``` - -async def main() -> None: - async with Client(mcp) as client: - print(client.server_capabilities.model_dump(exclude_none=True)) - - -asyncio.run(main()) +```console +python client.py ``` ```text @@ -118,8 +116,9 @@ Ce dictionnaire, ce sont les **capacités** déclarées de votre serveur. C’es Remarquez ce qui n’y figure pas. `completions` (la complétion automatique des arguments pour les modèles de ressources et les prompts) nécessite un gestionnaire que vous écrivez ; ce serveur n’en a pas, donc la capacité est absente et un client bien élevé ne demandera rien. C’est la règle pour tout ce qui est facultatif : enregistrez la chose et la capacité apparaît ; **[Complétions](../servers/completions.md)** le prouve. !!! info - `Client(mcp)` est le même client en mémoire avec lequel chaque exemple de cette documentation est testé, et - c’est ainsi que vous testerez les vôtres. Il a droit à une page entière : **[Tester](testing.md)**. + Ce `client.py` est un client MCP complet, et **[Le client](../client/index.md)** est sa page. + Dans un test, vous vous passez du terminal et du port et vous donnez à `Client` l’objet serveur lui-même, + `Client(mcp)`. Cela a aussi droit à une page entière : **[Tester](testing.md)**. ## Ce que vous n’avez pas écrit {#what-you-did-not-write} @@ -128,7 +127,7 @@ Reprenez cette page depuis le début. Vous avez écrit trois petites fonctions P * De JSON Schema. `a: int, b: int` *est* le schéma de `add`. * De gestionnaire de requêtes. `tools/list`, `resources/read`, `prompts/get` : tous servis pour vous. * De déclaration de capacités. `MCPServer` l’a faite pour vous. -* Une seule ligne de protocole. La négociation de version, l’encapsulation JSON-RPC, l’échange de capacités : tout cela s’est passé à l’intérieur de `mcp dev` et de `Client(mcp)`, et vous n’en avez rien vu. +* Une seule ligne de protocole. La négociation de version, l’encapsulation JSON-RPC, l’échange de capacités : tout cela s’est passé à l’intérieur de `mcp dev` et de `client.py`, et vous n’en avez rien vu. Ce rapport est tout l’intérêt du SDK. @@ -139,6 +138,6 @@ Ce rapport est tout l’intérêt du SDK. * Un décorateur par primitive : `@mcp.tool()`, `@mcp.resource(uri)`, `@mcp.prompt()`. Le nom, la description et le schéma viennent de la fonction. * Un URI avec un `{param}` crée un **modèle** de ressource, listé séparément des ressources concrètes. * Les **capacités** du serveur sont déclarées pour vous, et un client ne demande que ce qu’un serveur déclare. -* `Client(mcp)` se connecte à l’objet serveur en mémoire : votre banc d’essai dès le premier jour. +* `Client("http://localhost:8000/mcp")` parle à votre serveur en cours d’exécution. Donnez-lui plutôt l’objet serveur, `Client(mcp)`, et c’est votre banc d’essai dès le premier jour. La suite, c’est **[Se connecter à un vrai hôte](real-host.md)** : ce serveur dans Claude Desktop ou un IDE, pour de vrai. Puis **[Tester](testing.md)** : une page, un client en mémoire, et vous n’aurez plus jamais à deviner si cela fonctionne. Ensuite, chaque primitive a droit à sa propre page, en commençant par celle que pilote le modèle : **[Outils](../servers/tools.md)**. diff --git a/i18n/fr/pages/get-started/testing.md b/i18n/fr/pages/get-started/testing.md index c6464add..ab0e7d6a 100644 --- a/i18n/fr/pages/get-started/testing.md +++ b/i18n/fr/pages/get-started/testing.md @@ -1,13 +1,13 @@ --- translation: - sections: ['4926721070127497', c52a1de2b6b32f40, 8e792bf8c7489ec6, 627195f7159e24ef] + sections: [5d13c2f0ba42c0d2, c52a1de2b6b32f40, 8e792bf8c7489ec6, 38552ea228b0a04f] tool: 1 --- # Tests {#testing} -Le SDK Python fournit une classe `Client` dotée d’un **transport en mémoire** : passez-lui votre objet serveur et il s’y connecte directement. +La classe `Client` du SDK, celle-là même qui se connecte à une URL ou lance un sous-processus, se connecte aussi **en mémoire** : passez-lui votre objet serveur et elle lui parle directement. -Pas de sous-processus. Pas de port. Pas de transport du tout. C’est la même idée que le `TestClient` de FastAPI. +Pas de sous-processus. Pas de port. Rien sur la liaison. C’est la même idée que le `TestClient` de FastAPI. ## Utilisation de base {#basic-usage} @@ -98,15 +98,15 @@ change `raise_exceptions=True` : votre test voit le vrai message au lieu de la v Laissez-le activé dans les tests. Il n’a aucun sens dans du code de production. -## Dans le processus par défaut {#in-process-by-default} +## Neutre par défaut vis-à-vis de la génération du protocole {#era-neutral-by-default} !!! note `Client(mcp)` se connecte dans le processus et est **neutre vis-à-vis de la génération du protocole** par défaut : il sonde le serveur et choisit le chemin de protocole approprié. Fixez `mode="legacy"` si votre test exerce une sémantique propre aux connexions historiques (push d’échantillonnage (sampling) ou d’élicitation (elicitation), `message_handler`), et retirez alors - `raise_exceptions=True` : une connexion historique ne neutralise jamais rien, et l’indicateur - relève l’échec dans la tâche du serveur plutôt que dans votre test. + `raise_exceptions=True` : une connexion historique ne neutralise de toute façon jamais rien, et + l’indicateur relance l’échec à l’intérieur de la tâche du serveur plutôt que dans votre test. Cette unique ligne est aussi la raison pour laquelle cette documentation peut vous promettre que ses exemples fonctionnent : chaque fichier d’exemple est exercé par la propre suite de tests du diff --git a/i18n/fr/pages/handlers/multi-round-trip.md b/i18n/fr/pages/handlers/multi-round-trip.md index 661c2785..7d8e8d35 100644 --- a/i18n/fr/pages/handlers/multi-round-trip.md +++ b/i18n/fr/pages/handlers/multi-round-trip.md @@ -1,6 +1,6 @@ --- translation: - sections: [74011e683045eea9, 9b64cc175c18b6a9, 4b41be4824030397, e3b1502da786ec33, 71e41161f143c6a9, 9ec2c1eeb8c36378, 8dd027377d46448b, f81491125dcbfe8b] + sections: [74011e683045eea9, 9b64cc175c18b6a9, 4b41be4824030397, e3b1502da786ec33, 71e41161f143c6a9, 9ec2c1eeb8c36378, b47667184ca5b516, f81491125dcbfe8b] tool: 1 --- # Requêtes à plusieurs allers-retours (multi-round-trip) {#multi-round-trip-requests} @@ -164,7 +164,7 @@ Le `Server` bas niveau est le niveau sans rien de fourni d’office : contrairem ## Un résultat de la version 2026-07-28 {#a-2026-07-28-result} -`InputRequiredResult` n’existe qu’en version de protocole **2026-07-28**. Le `Client(server)` en mémoire la négocie pour vous ; sur la liaison, `mode="auto"` la découvre. Une fois connecté, `client.protocol_version` vous dit ce que vous avez obtenu. +`InputRequiredResult` n’existe qu’en version de protocole **2026-07-28**. Le `mode="auto"` par défaut de `Client` la découvre sur n’importe quelle connexion. Une fois connecté, `client.protocol_version` vous dit ce que vous avez obtenu. !!! warning Une session antérieure à 2026 n’a nulle part où mettre un `InputRequiredResult`. Renvoyez-en diff --git a/i18n/fr/pages/handlers/progress.md b/i18n/fr/pages/handlers/progress.md index 29182ac1..64b08fcf 100644 --- a/i18n/fr/pages/handlers/progress.md +++ b/i18n/fr/pages/handlers/progress.md @@ -1,6 +1,6 @@ --- translation: - sections: [5315262fe26b33e1, 9d8e98840f1b78f0, 0284b215e85366c4, 8534d8dbb4053a70, 2966fac6fe697007] + sections: [5315262fe26b33e1, 9d8e98840f1b78f0, 52d6009a07e770ea, 8534d8dbb4053a70, 2966fac6fe697007] tool: 1 --- # Progression {#progress} @@ -29,19 +29,17 @@ Trois arguments, et c’est vous qui décidez de leur sens : Le client active la fonctionnalité **appel par appel**, en passant `progress_callback=` à `call_tool` : -```python title="client.py" hl_lines="7 16" +```python title="client.py" hl_lines="5 14" import anyio from mcp import Client -from server import mcp - async def show(progress: float, total: float | None, message: str | None) -> None: print(f"{message} ({progress}/{total})") async def main() -> None: - async with Client(mcp) as client: + async with Client("http://localhost:8000/mcp") as client: result = await client.call_tool( "import_catalog", {"urls": ["https://example.com/a.json", "https://example.com/b.json"]}, @@ -56,23 +54,27 @@ anyio.run(main) La fonction de rappel (callback) est une fonction `async` qui prend exactement ce que le serveur a signalé : `progress`, `total`, `message`. !!! info - `Client(mcp)` se connecte directement à l’objet serveur, en mémoire : c’est le même client que celui sur lequel repose la page **[Tests](../get-started/testing.md)**. `progress_callback` est le même paramètre quel que soit le transport qu’utilise le `Client` ; le *timing* que vous allez observer est celui de la connexion en mémoire. Elle exécute votre fonction de rappel de façon synchrone, si bien que chaque signalement arrive avant que `call_tool` ne renvoie. Sur un vrai transport, les notifications font la course avec le résultat, et une fonction de rappel lente peut encore être en cours d’exécution après le retour de `call_tool`. + `progress_callback` est le même paramètre quoi que vous ayez passé à `Client` : une URL comme ici, un `StdioServerParameters`, ou l’objet serveur dans un test. Attention toutefois au timing sur un vrai transport. Chaque notification est acheminée seule, à côté de la réponse, si bien qu’une fonction de rappel lente peut encore être en cours d’exécution après le retour de `call_tool`. Seule la connexion de test en mémoire exécute la fonction de rappel de façon synchrone et garantit que chaque signalement arrive d’abord. ### Essayer {#try-it} -Placez `client.py` à côté de `server.py` et lancez-le : +Servez `server.py` en HTTP, puis lancez le client depuis un second terminal : + +```console +uv run mcp run server.py --transport streamable-http +``` ```console python client.py ``` ```text -Imported https://example.com/a.json (1/2) -Imported https://example.com/b.json (2/2) +Imported https://example.com/a.json (1.0/2.0) +Imported https://example.com/b.json (2.0/2.0) {'result': 'Imported 2 records.'} ``` -Chaque `await ctx.report_progress(...)` côté serveur est devenu un appel à `show` côté client, dans l’ordre, et les deux lignes se sont affichées **avant** que `call_tool` ne renvoie. La progression n’est pas empaquetée dans le résultat ; elle est diffusée pendant que l’outil travaille encore. +Chaque `await ctx.report_progress(...)` côté serveur est devenu un appel à `show` côté client, dans l’ordre. La progression n’est pas empaquetée dans le résultat. Elle est diffusée pendant que l’outil travaille encore. !!! warning `progress_callback` appartient à l’**appel**, pas au `Client`. Il n’existe aucun argument de constructeur pour cela, parce que des appels différents veulent des fonctions de rappel différentes : l’un pilote une barre de téléchargement, le suivant une ligne de journal. diff --git a/i18n/fr/pages/protocol-versions.md b/i18n/fr/pages/protocol-versions.md index a655ec8d..b9e120ce 100644 --- a/i18n/fr/pages/protocol-versions.md +++ b/i18n/fr/pages/protocol-versions.md @@ -1,6 +1,6 @@ --- translation: - sections: [478fd619e5f90ef8, aef094a00e44e248, bab8cbf3449fa7e9, df1809b15a58335b, 5f9d8c2336ed0239, f54974398e43ddef, b24443dd78584870] + sections: [d98e337bf28845e0, dd642acbba36f615, f547b3e76da19c94, 149513378588da5c, 4e149ed992046709, f54974398e43ddef, b24443dd78584870] tool: 1 --- # Versions du protocole {#protocol-versions} @@ -11,9 +11,17 @@ Les serveurs publiés avant la version 2026-07-28 ouvrent chaque connexion par l Vous n’avez presque jamais à vous en soucier, car `Client` négocie pour vous. Cette page porte sur le seul argument du constructeur qui contrôle cela, `mode=`, et sur les trois cas où vous le changez. +Chaque extrait de cette page est un `client.py` qui dialogue avec le `server.py` Bookshop de la page **[Le client](client/index.md)**. Lancez ce serveur dans un premier terminal : + +```console +uv run mcp run server.py --transport streamable-http +``` + +Puis exécutez chaque extrait dans un second terminal avec `python client.py`. + ## `mode="auto"` {#modeauto} -```python title="client.py" hl_lines="14-15" +```python title="client.py" hl_lines="7-8" --8<-- "docs_src/protocol_versions/tutorial001.py" ``` @@ -31,13 +39,14 @@ Dans les deux cas, vous ressortez connecté, et `client.protocol_version` vous i C’est toute la fonctionnalité. Un seul `Client`, un serveur de n’importe quelle génération, aucun branchement dans votre code. !!! info - `MCPServer` répond à `server/discover` sur tous les transports — en mémoire, stdio, Streamable - HTTP — donc face à votre propre serveur, `auto` aboutit toujours à `2026-07-28`. Le repli ne - se déclenche que face à un vrai serveur antérieur à 2026, c’est-à-dire exactement quand vous le souhaitez. + `MCPServer` répond à `server/discover` sur tous les transports — Streamable HTTP, stdio et la + connexion intra-processus qu’utilisent vos tests — donc face à votre propre serveur, `auto` aboutit + toujours à `2026-07-28`. Le repli ne se déclenche que face à un vrai serveur antérieur à 2026, + c’est-à-dire exactement quand vous le souhaitez. ## `mode="legacy"` {#modelegacy} -```python title="client.py" hl_lines="14" +```python title="client.py" hl_lines="7" --8<-- "docs_src/protocol_versions/tutorial002.py" ``` @@ -61,7 +70,7 @@ En version 2026-07-28, il a disparu. Le serveur *renvoie* ses questions et vous `mode` accepte aussi une chaîne de version moderne du protocole. Aujourd’hui, cet ensemble est exactement `["2026-07-28"]`. -```python title="client.py" hl_lines="14" +```python title="client.py" hl_lines="7" --8<-- "docs_src/protocol_versions/tutorial003.py" ``` @@ -94,7 +103,7 @@ La sonde est peu coûteuse, mais cela reste un aller-retour que vous payez à ch Alors conservez-la. Après une connexion `auto`, `client.session.discover_result` contient le `DiscoverResult` exact que le serveur a envoyé : ses `supported_versions`, ses `capabilities`, ses `instructions` et l’identité que le serveur a inscrite dans le `_meta` du résultat. Repassez-le via `prior_discover=` la fois suivante : -```python title="client.py" hl_lines="15 17" +```python title="client.py" hl_lines="8 10" --8<-- "docs_src/protocol_versions/tutorial004.py" ``` diff --git a/i18n/fr/pages/run/asgi.md b/i18n/fr/pages/run/asgi.md index 288262c6..d2821f71 100644 --- a/i18n/fr/pages/run/asgi.md +++ b/i18n/fr/pages/run/asgi.md @@ -1,6 +1,6 @@ --- translation: - sections: [1062ef792791488a, 4be2b831547184a9, 374b049e770385f2, b72f6947089e6de0, b172c9db7831bb31, 70b9ece244ca1b0c, cba78e052898c3f6, f06bdb541cb0b469, fb82d526320b7cc3] + sections: [1062ef792791488a, 4be2b831547184a9, 374b049e770385f2, b72f6947089e6de0, b172c9db7831bb31, 10394c6f16601638, cba78e052898c3f6, f06bdb541cb0b469, 6dc898ccc5a903f9] tool: 1 --- # Ajouter à une application existante {#add-to-an-existing-app} @@ -99,7 +99,7 @@ Ce `/mcp` final, c’est `streamable_http_path`. Définissez-le à `"/"` et le p --8<-- "docs_src/asgi/tutorial004.py" ``` -Les clients se connectent désormais à `/notes`, et non à `/notes/mcp`. +Les clients se connectent désormais à `/notes/`, et non à `/notes/mcp`. ## CORS pour les clients navigateur {#cors-for-browser-clients} @@ -142,4 +142,4 @@ Un client qui s’exécute dans un navigateur a besoin de deux permissions de vo * Les clients navigateur ont besoin de CORS : `allow_headers` pour les en-têtes de requête `Mcp-*`, `expose_headers=["Mcp-Session-Id"]` pour la réponse. * `@mcp.custom_route()` ajoute des points de terminaison HTTP ordinaires, non authentifiés, à côté de `/mcp`. -Une fois le serveur joignable à une vraie URL, **[Le client](../client/index.md)** s’y connecte avec cette URL plutôt qu’avec un objet serveur. +Une fois le serveur joignable à une vraie URL, **[Le client](../client/index.md)** s’y connecte avec cette URL. diff --git a/i18n/fr/pages/run/authorization.md b/i18n/fr/pages/run/authorization.md index 57e515a5..5726f506 100644 --- a/i18n/fr/pages/run/authorization.md +++ b/i18n/fr/pages/run/authorization.md @@ -1,6 +1,6 @@ --- translation: - sections: [d62c13457fc4a534, 80e73abaca6e0652, d1dc4c54cd00ec9c, 14ad3bc7904036bb, 5225f127bc1b9c77, fe1626fdd5aad1da, 4556cb7ea1a04a31] + sections: [d62c13457fc4a534, 80e73abaca6e0652, 128a492d18295f64, 14ad3bc7904036bb, 54a6697833fcc1d9, fe1626fdd5aad1da, 811d083c1da8bcf6] tool: 1 --- # Autorisation {#authorization} @@ -23,12 +23,12 @@ C’est tout le triangle. Toute cette page porte sur le point du milieu. Le SDK n’a aucun avis sur ce à quoi ressemble un jeton valide. C’est vous qui le lui dites, en implémentant **`TokenVerifier`** : -```python title="server.py" hl_lines="12-14 19-24" +```python title="server.py" hl_lines="14-16 21-27" --8<-- "docs_src/authorization/tutorial001.py" ``` * `TokenVerifier` est un protocole avec une seule méthode asynchrone. `verify_token` reçoit le jeton brut de l’en-tête `Authorization` et renvoie un **`AccessToken`** s’il est valide, `None` sinon. Il n’y a rien d’autre à implémenter. -* Celui-ci cherche le jeton dans une table. Un vérificateur réel vérifie la signature d’un JWT ou appelle le point de terminaison d’introspection de jetons du serveur d’autorisation. Ce code est le vôtre ; le SDK ne fait que l’appeler. +* Celui-ci cherche le jeton dans une table ; chaque entrée consigne la ressource pour laquelle le jeton a été émis. Un vérificateur réel vérifie la signature d’un JWT ou appelle le point de terminaison d’introspection de jetons du serveur d’autorisation, et indique pour qui le jeton a été émis (son `aud`) dans `AccessToken.resource`. Ce code est le vôtre ; le SDK ne fait que l’appeler. * `token_verifier=` et `auth=` vont toujours de pair. Passez l’un sans l’autre et `MCPServer(...)` lève une `ValueError` avant même de servir la moindre requête. `AuthSettings` est la face publique de votre serveur de ressources : @@ -36,6 +36,10 @@ Le SDK n’a aucun avis sur ce à quoi ressemble un jeton valide. C’est vous q * `issuer_url` : le serveur d’autorisation qui émet vos jetons. * `resource_server_url` : l’URL publique de ce point de terminaison MCP. Elle désigne *quelle* ressource un jeton vise, et c’est là que réside le document de découverte. * `required_scopes` : chaque jeton doit tous les porter. +* `validate_token_resource` : refuser tout jeton dont `AccessToken.resource` n’est pas `resource_server_url`. Le laisser non défini alors que `resource_server_url` est défini produit un avertissement (`MCPDeprecationWarning`) et se comporte comme `False` ; la version 3.0 fait de `True` la valeur par défaut pour les serveurs de ressources. + * Activez-le lorsque votre serveur d’autorisation lie les jetons à la `resource` demandée par le client, que les clients MCP envoient toujours. Gardez dans `resource_server_url` l’URL exacte à laquelle les clients se connectent. + * Laissez-le désactivé lorsque votre serveur d’autorisation utilise ses propres identifiants d’audience (un identifiant d’API Auth0, un ID d’application Entra) et vérifiez plutôt `aud` dans votre vérificateur, en renvoyant `None` pour un jeton qui n’est pas destiné à ce serveur. + * Si `aud` est une liste, placez dans `resource` l’entrée égale à `resource_server_url`. !!! tip `examples/servers/simple-auth/` dans le dépôt du SDK contient un `IntrospectionTokenVerifier` qui appelle @@ -91,7 +95,7 @@ C’est grâce à ce document qu’un client qui n’a jamais entendu parler de Dans n’importe quel gestionnaire (handler), **`get_access_token()`** est l’objet `AccessToken` que votre vérificateur a renvoyé pour la requête en cours : -```python title="server.py" hl_lines="4 32-35" +```python title="server.py" hl_lines="4 35-38" --8<-- "docs_src/authorization/tutorial002.py" ``` @@ -125,6 +129,6 @@ Un serveur d’autorisation peut aussi accepter l’assertion signée d’un fou * `token_verifier=` et `auth=AuthSettings(issuer_url=..., resource_server_url=..., required_scopes=[...])` vont toujours de pair. * Le SDK publie les métadonnées de ressource protégée (Protected Resource Metadata) de la [RFC 9728](https://datatracker.ietf.org/doc/html/rfc9728) sur `/.well-known/oauth-protected-resource/...` et répond aux requêtes non authentifiées par un 401 dont l’en-tête `WWW-Authenticate` pointe vers elles. C’est tout le mécanisme de découverte. * `get_access_token()` dans n’importe quel gestionnaire indique qui appelle. -* L’autorisation est une affaire de HTTP. `stdio` et le client en mémoire ne la voient jamais. +* L’autorisation est une affaire de HTTP. `stdio` et le client de test en mémoire ne la voient jamais. La moitié client (découvrir votre serveur d’autorisation et récupérer le jeton pour vous), c’est **[Clients OAuth](../client/oauth-clients.md)**. Et un client qui *affirme* une identité au lieu d’en demander une à un utilisateur, c’est **[Assertion d’identité](../client/identity-assertion.md)**. diff --git a/i18n/fr/pages/run/deploy.md b/i18n/fr/pages/run/deploy.md index 14df6230..91ba48e4 100644 --- a/i18n/fr/pages/run/deploy.md +++ b/i18n/fr/pages/run/deploy.md @@ -1,6 +1,6 @@ --- translation: - sections: [28221886b198784f, f88ea1f1614f3a1d, ce926d686730b6d0, 3be24f8ad8bb5ab9, 3fad24032b2224ff, f25a7f860e579ecb, e758745df6fb7b0a] + sections: [28221886b198784f, f88ea1f1614f3a1d, 2e76f5cb9df15042, ce926d686730b6d0, 3be24f8ad8bb5ab9, 3fad24032b2224ff, f25a7f860e579ecb, 697b01d95080880d] tool: 1 --- # Déployer et passer à l’échelle {#deploy-scale} @@ -48,6 +48,22 @@ Déployée derrière un vrai nom d’hôte, cette même valeur par défaut rejet de liste des hôtes autorisés jusqu’à preuve du contraire. **[Dépannage](../troubleshooting.md)** commence aussi par là. +## Derrière un proxy de terminaison TLS {#behind-a-tls-terminating-proxy} + +Si TLS se termine sur un proxy (un ingress, un répartiteur de charge, Caddy, nginx) et qu’uvicorn sert du HTTP en clair derrière lui, dites à uvicorn de faire confiance aux en-têtes `X-Forwarded-*` du proxy : + +```console +uvicorn server:app --proxy-headers --forwarded-allow-ips='' +``` + +Sans cela, l’application croit être servie en `http://`, et toute redirection qu’elle émet (la plus courante est `/mcp` → `/mcp/`) pointe vers `http://…`. Le client Python refuse de suivre une redirection d’un point de terminaison HTTPS vers du HTTP en clair, et le dit : + +```text +MCPError: Redirect to http://mcp.example.com/mcp/ not followed: it would downgrade this HTTPS endpoint to plain HTTP. +``` + +Le palliatif côté client consiste à configurer l’URL exacte que sert le serveur (`https://mcp.example.com/mcp/`, barre oblique finale comprise) pour qu’aucune redirection n’ait lieu. Le correctif, c’est l’option ci-dessus. `FORWARDED_ALLOW_IPS` en est la forme variable d’environnement ; `*` fait confiance à chaque saut, ce qui n’est correct que si rien d’autre que le proxy ne peut atteindre uvicorn. + ## Les workers, et qui a besoin d’affinité {#workers-and-who-has-to-be-sticky} Une fois que le nom d’hôte répond, placez plus d’un worker derrière lui. Le SDK n’a aucun réglage pour cela ; vous passez une application Starlette à l’échelle comme n’importe quelle application ASGI, en confiant l’objet à quelque chose qui sait créer des processus (fork) : @@ -173,6 +189,7 @@ Un `MCPServer` est une implémentation du protocole, pas un serveur d’applicat ## Récapitulatif {#recap} * Par défaut, l’application ne répond qu’aux requêtes adressées à localhost. `transport_security=TransportSecuritySettings(allowed_hosts=[...], allowed_origins=[...])` est le passage obligé avant la mise en production : tant que vous ne le passez pas, chaque requête derrière un vrai nom d’hôte est un `421` et la raison n’est que dans le journal du serveur. +* Derrière un proxy de terminaison TLS, lancez uvicorn avec `--proxy-headers --forwarded-allow-ips=...`, sans quoi ses redirections pointent vers `http://` et le client les refuse. * En version 2026-07-28, il n’y a pas de session et rien sur quoi un répartiteur de charge pourrait établir une affinité. `stateless_http=True` est un réglage réservé à la branche historique, parce qu’une requête moderne est routée et traitée avant même que cet indicateur soit lu. * La clé `requestState` par défaut est `os.urandom(32)`, générée par processus. Une nouvelle tentative à plusieurs allers-retours qui atteint un autre worker échoue avec `-32602` *« Invalid or expired requestState »*. * Le correctif est `RequestStateSecurity(keys=[...])` **et** le même nom de serveur sur chaque instance. Le nom est la revendication d’audience par défaut du jeton. Mêmes clés, même nom. diff --git a/i18n/fr/pages/run/index.md b/i18n/fr/pages/run/index.md index 27adf135..d528c375 100644 --- a/i18n/fr/pages/run/index.md +++ b/i18n/fr/pages/run/index.md @@ -1,6 +1,6 @@ --- translation: - sections: [fea8d769ff9edeba, ce8e2ad42f29ef71, 0d705efb19cf99c2, 2fd7cf825e6d2b2c, 9adc400e8c88e854, 318893ad8e2e9924, 6b63ab96b34476c0] + sections: [fea8d769ff9edeba, ce8e2ad42f29ef71, 0d705efb19cf99c2, 9fd2357154a5b7e7, 9adc400e8c88e854, 318893ad8e2e9924, 6b63ab96b34476c0] tool: 1 --- # Exécuter votre serveur {#running-your-server} @@ -75,6 +75,11 @@ Chaque transport a ses propres arguments nommés, tous sur `run()` : * `max_request_body_size` : taille maximale acceptée pour le corps d’une requête, en octets. Vaut 4 Mio par défaut ; les requêtes plus grandes reçoivent un HTTP 413 avant toute analyse ou création de session. Ne l’augmentez que lorsque des messages MCP légitimes dépassent cette taille. +* `session_idle_timeout` : nombre de secondes pendant lesquelles une session historique peut rester sans rien en cours avant que le + serveur ne la ferme. Valeur par défaut 1800. `None` le désactive. Voir + [Durée de vie des sessions et limites](legacy-clients.md#session-lifetime-and-limits). +* `max_sessions` : nombre de sessions historiques qu’un même processus conserve simultanément. Valeur par défaut 10 000. `None` + supprime la limite. Traité dans la même section. * `event_store`, `retry_interval`, `transport_security` : reprise après coupure et protection contre le DNS rebinding. Ils peuvent attendre, jusqu’à ce que vous déployiez ailleurs que sur localhost ; **[Déployer et passer à l’échelle](deploy.md)** couvre `transport_security`. !!! warning diff --git a/i18n/fr/pages/run/legacy-clients.md b/i18n/fr/pages/run/legacy-clients.md index 80b1f796..c50f3763 100644 --- a/i18n/fr/pages/run/legacy-clients.md +++ b/i18n/fr/pages/run/legacy-clients.md @@ -1,6 +1,6 @@ --- translation: - sections: [3d1663c18edc824c, d4fd37009a13f03d, af9f398a5a8b679a, 470c2dd144294d69, 8e45827e6d24e8c8, 91dfd0ce98ebb03c] + sections: [3d1663c18edc824c, 90956965ae6a1ca1, af9f398a5a8b679a, 5ce83b1f9d88da62, 0d9b5d13fffc94e5, 8e45827e6d24e8c8, 91dfd0ce98ebb03c] tool: 1 --- # Prendre en charge les clients historiques {#serving-legacy-clients} @@ -22,15 +22,25 @@ Un client historique n’est donc pas quelque chose *pour* lequel vous construis ## Un gestionnaire, deux générations {#one-handler-both-eras} -Voici un outil (tool) qui doit demander quelque chose à l’utilisateur, et des clients des deux générations qui l’appellent : +Voici un outil (tool) qui doit demander quelque chose à l’utilisateur : -```python title="server.py" hl_lines="24 37-38" +```python title="server.py" hl_lines="21" --8<-- "docs_src/legacy_clients/tutorial001.py" ``` `reserve` a besoin d’une chose que le modèle n’a pas fournie : le nombre d’exemplaires. `Annotated[..., Resolve(ask_quantity)]` est la façon dont un outil le déclare (tous les détails sont dans **[Dépendances](../handlers/dependencies.md)**). Rien dans `reserve` ne nomme une version, ne vérifie une capacité ni ne bifurque. -Les deux clients sont ouverts **en même temps**, sur le même objet `mcp`. `mode="legacy"` exécute la poignée de main `initialize` : exactement la connexion qu’ouvre un client antérieur à 2026. L’autre prend la valeur par défaut et arrive en version `2026-07-28`. +Servez-le en HTTP, et voici des clients des deux générations qui l’appellent : + +```console +uv run mcp run server.py --transport streamable-http +``` + +```python title="client.py" hl_lines="14-15" +--8<-- "docs_src/legacy_clients/tutorial001_client.py" +``` + +Les deux clients sont ouverts **en même temps**, sur le même serveur en cours d’exécution. `mode="legacy"` exécute la poignée de main `initialize` : exactement la connexion qu’ouvre un client antérieur à 2026. L’autre prend la valeur par défaut et arrive en version `2026-07-28`. Lancez `python client.py` depuis un second terminal : ```text 2025-11-25 {'result': "Reserved 2 of 'Dune'."} @@ -63,6 +73,41 @@ Sur un seul worker, c’est invisible. Sur deux, c’est tout le problème : une événements SSE manqués pour un client qui se reconnecte à la *même* session), pas un magasin de sessions. Il ne rend jamais une session accessible depuis un autre processus. +## Durée de vie et limites des sessions {#session-lifetime-and-limits} + +Une session historique ne vit pas éternellement, et un processus n’en conserve pas un nombre +illimité. Deux réglages contrôlent cela. Tous deux sont des arguments nommés de `run()`, +`streamable_http_app()` et `Server.streamable_http_app()`. Les connexions modernes (`2026-07-28`) et +`stateless_http=True` n’ont pas de sessions, si bien qu’aucun des deux réglages ne s’y applique. + +| Réglage | Valeur par défaut | Ce qu’il fait | Ce que voit le client | Pour le désactiver | +|---|---|---|---|---| +| `session_idle_timeout` | `1800` (30 min) | Ferme une session qui n’a rien eu en cours pendant cette durée. | `404 Session not found`. Il doit refaire `initialize`. | `None` | +| `max_sessions` | `10_000` | Refuse d’ouvrir une session au-delà de ce nombre. Les sessions existantes ne sont pas touchées et rien n’est évincé. | `503 Too many open sessions` avec le code JSON-RPC `-32603`. | `None` | + +Ce qui compte comme « en cours » : + +* Un flux `GET` ouvert. Les clients du SDK en gardent un ouvert, si bien que la session d’un client + connecté n’expire jamais. +* Une requête à laquelle on est encore en train de répondre. Un appel d’outil qui dure plus longtemps + que le délai n’est pas interrompu, et le compte à rebours ne démarre qu’une fois qu’il a terminé. +* Rien d’autre. Entre les requêtes, l’horloge tourne. N’importe quelle requête sur la session la + réinitialise, `ping` compris. Une fois qu’une session a expiré, rien ne la ranime. + +Un client qui met fin à sa session par un `DELETE` la libère immédiatement. Même chose pour un client +dont la requête d’ouverture a été refusée. + +```python +mcp.run(transport="streamable-http", session_idle_timeout=None, max_sessions=50_000) +``` + +Les deux événements apparaissent dans le journal du serveur. Une expiration donne `Session idle timeout` +au niveau `INFO`. Une ouverture refusée donne `Refusing to open a new session: sessions are already open` +au niveau `WARNING`. + +Les limites s’entendent par processus. Avec quatre workers, le plafond est quatre fois `max_sessions`, et +chaque worker fait expirer ses propres sessions. + ## Le seul réglage : `stateless_http` {#the-one-knob-stateless_http} Si l’affinité est un coût que vous refusez de payer, il y a exactement une chose que vous pouvez changer. @@ -88,8 +133,8 @@ Deux choses à son sujet comptent plus que ce qu’il fait. !!! check Faites la mauvaise chose. `reserve` est exactement l’outil qui vient de servir les deux clients. - Déployez-le avec `stateless_http=True`, connectez les deux mêmes clients en HTTP et appelez-le - depuis chacun. + Déployez-le avec `stateless_http=True`, connectez les deux mêmes clients et appelez-le depuis + chacun. Le client moderne obtient toujours `Reserved 2 of 'Dune'.` La voie moderne n’a pas changé. diff --git a/i18n/fr/pages/troubleshooting.md b/i18n/fr/pages/troubleshooting.md index c928c6b7..1cfa29d8 100644 --- a/i18n/fr/pages/troubleshooting.md +++ b/i18n/fr/pages/troubleshooting.md @@ -1,6 +1,6 @@ --- translation: - sections: [2efaecdef109a5c5, fcacd3e66b8635a4, 25323d737dcf0261, 8a6e351ec756904d, 137454d469c867f5, 6392596bd6df54f0, 41126fa9c4fe432f, 480b6d7897e30ab4, d83bb682e708dde0, ebbed3449c499db4, 323ef84f6b4bebde, 30fd31be74169d9a, 656943c6cb567218, c2dc3b1007d2e987, 7cf5386b997d04e9, 0b59feed8384456e, 0cba47bae78d04eb, e4355f4c7cf4fb2e] + sections: [3d58228e81b99543, 170514ce901c4139, 17d61fad0a50d62b, 8a6e351ec756904d, 137454d469c867f5, 6392596bd6df54f0, 41126fa9c4fe432f, 480b6d7897e30ab4, d83bb682e708dde0, ebbed3449c499db4, 525cdf1755e29d4c, 30fd31be74169d9a, d2e88333d4f7841f, c2dc3b1007d2e987, d6eabf60cc366341, f798e815252852c2, 0cba47bae78d04eb, 2c218ba829abf74e] tool: 1 --- # Dépannage {#troubleshooting} @@ -13,6 +13,12 @@ Plusieurs entrées s’appuient sur ce même serveur. Un outil (tool) et une res --8<-- "docs_src/troubleshooting/tutorial001.py" ``` +Ces entrées le joignent à l’adresse `http://localhost:8000/mcp`, laissez-le donc tourner en HTTP : + +```console +uv run mcp run server.py --transport streamable-http +``` + Les erreurs citées sur cette page sont réelles : la suite de tests du SDK reproduit chacune d’entre elles. ## `ExceptionGroup: unhandled errors in a TaskGroup (1 sub-exception)` {#exceptiongroup-unhandled-errors-in-a-taskgroup-1-sub-exception} @@ -23,7 +29,7 @@ Ce n’est pas une erreur MCP. C’est du bruit produit par anyio, et votre vrai ```python async def main() -> None: - async with Client(mcp) as client: + async with Client("http://localhost:8000/mcp") as client: await client.read_resource("weather://Atlantis") ``` @@ -49,7 +55,7 @@ Deux choses à faire avec cela : ```python async def main() -> None: - async with Client(mcp) as client: + async with Client("http://localhost:8000/mcp") as client: try: await client.read_resource("weather://Atlantis") except MCPError as e: @@ -67,7 +73,7 @@ async def main() -> None: ```python async def main() -> None: - client = Client(mcp) + client = Client("http://localhost:8000/mcp") tools = await client.list_tools() # RuntimeError ``` @@ -75,7 +81,7 @@ Entrez-y. `__aenter__` est la connexion : ```python async def main() -> None: - async with Client(mcp) as client: + async with Client("http://localhost:8000/mcp") as client: tools = await client.list_tools() ``` @@ -252,7 +258,7 @@ app = Starlette(routes=[Mount("/", app=mcp.streamable_http_app())], lifespan=lif ## `MCPError: Session not found` {#mcperror-session-not-found} -Le serveur ne reconnaît pas le `Mcp-Session-Id` que votre client a envoyé, presque toujours parce que le serveur a **redémarré** (ou que vous avez été routé vers une autre instance). Les sessions vivent dans la mémoire de ce seul processus. +Le serveur ne reconnaît pas le `Mcp-Session-Id` que votre client a envoyé. Soit le serveur a **redémarré** (ou vous avez été routé vers une autre instance), soit la session a **expiré** parce que rien n’était en cours pendant `session_idle_timeout`, qui vaut 30 minutes par défaut. Voir [Durée de vie et limites des sessions](run/legacy-clients.md#session-lifetime-and-limits). Les sessions vivent dans la mémoire de ce seul processus. Il n’y a pas de bogue serveur à trouver. La réponse HTTP est un `404` dont le corps *est* du JSON-RPC, donc, contrairement au `421` ci-dessus, le `Client` python vous montre celui-ci mot pour mot : @@ -262,9 +268,9 @@ Il n’y a pas de bogue serveur à trouver. La réponse HTTP est un `404` dont l Le correctif est de vous reconnecter : quittez le bloc `async with Client(...)` et entrez dans un nouveau, qui négocie une session neuve. Pour un client de longue durée, cela signifie intercepter `MCPError` autour de vos appels et vous reconnecter sur ce message plutôt que de réessayer dans une session morte. -Si cela arrive *sans* redémarrage, vous exécutez plus d’un worker sans sessions persistantes (sticky sessions) : chaque worker détient sa propre table de sessions, donc une requête routée vers le mauvais atterrit ici. **[Déployer et passer à l’échelle](run/deploy.md)** et **[Prendre en charge les clients historiques](run/legacy-clients.md)** traitent ce sujet et ses deux correctifs (routage persistant, ou `stateless_http=True`). +Si cela arrive *sans* redémarrage et sans que le client soit resté silencieux aussi longtemps, vous exécutez plus d’un worker sans sessions persistantes (sticky sessions) : chaque worker détient sa propre table de sessions, donc une requête routée vers le mauvais atterrit ici. **[Déployer et passer à l’échelle](run/deploy.md)** et **[Prendre en charge les clients historiques](run/legacy-clients.md)** traitent ce sujet et ses deux correctifs (routage persistant, ou `stateless_http=True`). -Pour l’opérateur du serveur, la ligne de journal correspondante est `Rejected request with unknown or expired session ID: `. Elle est journalisée au niveau `INFO`, elle est donc invisible au seuil habituel `WARNING`. La voir par rafales juste après un déploiement est normal ; chaque client connecté se reconnecte. +Pour l’opérateur du serveur, la ligne de journal correspondante est `Rejected request with unknown or expired session ID: `. Elle est journalisée au niveau `INFO`, elle est donc invisible au seuil habituel `WARNING`. La voir par rafales juste après un déploiement est normal ; chaque client connecté se reconnecte. Lorsque la session a plutôt expiré, cette ligne est précédée de `Session idle timeout`, également au niveau `INFO`. ## `MCPError: Method not found` {#mcperror-method-not-found} @@ -276,7 +282,13 @@ Une chose ne produit **pas** cette erreur, bien qu’il s’agisse d’une requ Votre serveur veut demander quelque chose à l’utilisateur, et ce client n’a jamais dit qu’on pouvait l’interroger. -Un résolveur d’élicitation (elicitation) refuse d’emblée lorsque le client connecté n’a pas déclaré l’élicitation par formulaire, et `e.error.data` nomme exactement ce qui manque : +Ce Bistro pose la question avant de réserver, via un résolveur : + +```python title="server.py" hl_lines="15-17 21" +--8<-- "docs_src/troubleshooting/tutorial007.py" +``` + +Servez-le à la place du serveur Weather et appelez `book_table` depuis un client qui n’a passé aucun `elicitation_callback`. Le résolveur refuse d’emblée, parce que le client connecté n’a jamais déclaré l’élicitation (elicitation) par formulaire, et `e.error.data` nomme exactement ce qui manque : ```json { @@ -290,7 +302,7 @@ Passez `elicitation_callback=` à `Client(...)`. Enregistrer la fonction de rapp ```python async def main() -> None: - async with Client(mcp, elicitation_callback=handle_elicitation) as client: + async with Client("http://localhost:8000/mcp", elicitation_callback=handle_elicitation) as client: result = await client.call_tool("book_table", {"date": "Friday"}) ``` @@ -316,14 +328,14 @@ Vous voyez celui-ci depuis `ctx.elicit()` sur une connexion historique, et sur n Votre gestionnaire a tenté de joindre le client en cours de requête, sur une connexion dont l’appel n’a aucun canal capable de transporter une requête venant du serveur. Trois configurations de serveur placent un appel dans cette situation. -**Une connexion `2026-07-28` : n’importe quel transport, toujours.** Le protocole moderne n’a aucune requête à l’initiative du serveur, si bien que le serveur refuse avant que quoi que ce soit ne soit envoyé. `ctx.elicit()` dans un outil est la façon classique de la rencontrer (dès le tout premier test en mémoire, puisque `Client(server)` négocie `2026-07-28` sans qu’on le lui demande), et passer `elicitation_callback=` ne change rien, parce qu’aucune requête n’atteint jamais le client pour qu’il y réponde : +**Une connexion `2026-07-28` : n’importe quel transport, toujours.** Le protocole moderne n’a aucune requête à l’initiative du serveur, si bien que le serveur refuse avant que quoi que ce soit ne soit envoyé. `ctx.elicit()` dans un outil est la façon classique de la rencontrer, le plus souvent dès le tout premier **[test](get-started/testing.md)** en mémoire de cet outil, puisque `Client(mcp)` négocie `2026-07-28` sans qu’on le lui demande. Passer `elicitation_callback=` ne change rien, parce qu’aucune requête n’atteint jamais le client pour qu’il y réponde : ```python title="server.py" hl_lines="16" --8<-- "docs_src/troubleshooting/tutorial006.py" ``` ```python -async def main() -> None: +async def test_book_table() -> None: async with Client(mcp) as client: await client.call_tool("book_table", {"date": "Friday"}) ``` @@ -366,7 +378,7 @@ Le serveur n’a pas pu vérifier le jeton `requestState` que votre client a ren ```python async def main() -> None: - async with Client(mcp) as client: + async with Client("http://localhost:8000/mcp") as client: await client.call_tool("forecast", {"city": "London"}, request_state="round-1-from-worker-a") ``` @@ -419,7 +431,7 @@ mcp = MCPServer("Weather", request_state_security=RequestStateSecurity(keys=[key * `Tool already exists:` dans le journal du serveur est le seul signe que deux outils de même nom se sont fondus en un seul. * Un 421, trois formulations : `Server returned an error response` (le `Client` python), `421 Misdirected Request` / `Invalid Host header` (tout le reste), `Invalid Host header: ` (le journal du serveur). Correctif : `transport_security=TransportSecuritySettings(allowed_hosts=[...])`. * `Task group is not initialized` -> une application montée dont le cycle de vie de l’hôte n’est jamais entré dans `mcp.session_manager.run()`. -* `Session not found` -> le serveur a redémarré ; reconnectez-vous. +* `Session not found` -> le serveur a redémarré ou la session a expiré (`session_idle_timeout`) ; reconnectez-vous. * `Cannot send 'elicitation/create': ... no back-channel ...` -> `ctx.elicit()` a besoin d’un canal serveur-vers-client : une connexion `2026-07-28` n’en a jamais, `stateless_http=True` retire celui des connexions historiques, et `json_response=True` retire celui attaché à la requête. Utilisez un résolveur (un client historique a aussi besoin d’un serveur qui conserve le canal). Son voisin `Method not found` est une requête pour une méthode que la révision du protocole de l’autre côté ne possède pas. * `Client did not declare the form elicitation capability ...` et `Elicitation not supported` -> il manque `elicitation_callback=` au client. * `Invalid or expired requestState` ne dit jamais pourquoi sur la liaison. Le journal du serveur, si ; `unknown key` signifie qu’il faut partager `RequestStateSecurity(keys=[...])` entre les workers. diff --git a/i18n/fr/pages/whats-new.md b/i18n/fr/pages/whats-new.md index e8a36aac..28a15ebb 100644 --- a/i18n/fr/pages/whats-new.md +++ b/i18n/fr/pages/whats-new.md @@ -1,6 +1,6 @@ --- translation: - sections: [cfe01c0c5863dfa2, 1c58c5cfcc37d455, a7392996acf1ad8f, 875eb2889263424e] + sections: [cfe01c0c5863dfa2, 0dbb68d8b210177b, 80cf193023af3ed4, 875eb2889263424e] tool: 1 --- # Nouveautés de la v2 {#whats-new-in-v2} @@ -43,11 +43,11 @@ Tout ce dont un outil a besoin ne devrait pas venir du modèle. Nouveauté de la La v1 vous donnait trois couches imbriquées : un gestionnaire de contexte de transport produisant des flux bruts, une `ClientSession` qui les enveloppait et un `await session.initialize()` appelé à la main. La v2 a un seul objet : -```python title="client.py" hl_lines="14-18" ---8<-- "docs_src/client/tutorial001.py" +```python title="client.py" hl_lines="7-11" +--8<-- "docs_src/client/tutorial001_client.py" ``` -`Client` accepte un objet serveur (en mémoire, sans transport : c’est la solution pour les tests), une URL (Streamable HTTP), un `StdioServerParameters` (un sous-processus stdio) ou n’importe quel autre gestionnaire de contexte de transport comme `sse_client(...)`. Entrer dans `async with` établit la connexion et négocie la version du protocole, quelle que soit la génération que parle le serveur ; `client.server_capabilities` et `client.protocol_version` sont simplement disponibles ensuite, et `client.server_info` aussi lorsque le serveur s’identifie (c’est désormais `Implementation | None`, puisque l’identité est optionnelle dans la génération 2026). Les fonctions de rappel (callbacks) d’échantillonnage et d’élicitation que vous aviez enregistrées en v1 fonctionnent toujours (leur corps voit le même renommage d’attributs en snake_case que tout le reste de cette page), elles répondent désormais aussi aux requêtes-dans-les-résultats de style 2026 (ci-dessous), et elles s’exécutent de façon concurrente plutôt qu’une à la fois. `ClientSession` reste en dessous pour qui veut la surface bas niveau, et `client.session` vous la donne ; elle a bougé elle aussi (elle tourne sur le nouveau moteur de répartition, et certaines de ses propres signatures ont changé), alors lisez le **[Guide de migration](migration.md#clientsession-now-runs-on-jsonrpcdispatcher-basesession-removed)** avant de descendre à ce niveau. +`Client` accepte une URL (Streamable HTTP), un `StdioServerParameters` (un sous-processus stdio), n’importe quel autre gestionnaire de contexte de transport comme `sse_client(...)` ou, dans les tests, l’objet serveur lui-même (en mémoire, sans transport). Entrer dans `async with` établit la connexion et négocie la version du protocole, quelle que soit la génération que parle le serveur ; `client.server_capabilities` et `client.protocol_version` sont simplement disponibles ensuite, et `client.server_info` aussi lorsque le serveur s’identifie (c’est désormais `Implementation | None`, puisque l’identité est optionnelle dans la génération 2026). Les fonctions de rappel (callbacks) d’échantillonnage et d’élicitation que vous aviez enregistrées en v1 fonctionnent toujours (leur corps voit le même renommage d’attributs en snake_case que tout le reste de cette page), elles répondent désormais aussi aux requêtes-dans-les-résultats de style 2026 (ci-dessous), et elles s’exécutent de façon concurrente plutôt qu’une à la fois. `ClientSession` reste en dessous pour qui veut la surface bas niveau, et `client.session` vous la donne ; elle a bougé elle aussi (elle tourne sur le nouveau moteur de répartition, et certaines de ses propres signatures ont changé), alors lisez le **[Guide de migration](migration.md#clientsession-now-runs-on-jsonrpcdispatcher-basesession-removed)** avant de descendre à ce niveau. **[Le Client](client/index.md)** le présente, **[Transports du client](client/transports.md)** couvre les quatre formes de connexion, **[Fonctions de rappel du client](client/callbacks.md)** couvre les fonctions de rappel elles-mêmes, et **[Tests](get-started/testing.md)** montre le modèle en mémoire qui remplace l’utilitaire `create_connected_server_and_client_session()` de la v1. @@ -172,11 +172,15 @@ Toutes les requêtes initiées par le serveur disparaissent en version 2026-07-2 Le remplacement inverse l’appel. Un outil qui a besoin de quelque chose de la part de l’utilisateur *renvoie* la question (`InputRequiredResult`), le client y répond avec les mêmes fonctions de rappel qu’il a toujours eues, et l’appel est relancé avec les réponses jointes. `Client` pilote cette boucle pour vous. Côté serveur, vous construisez rarement le résultat vous-même, car une **[dépendance](handlers/dependencies.md)** le fait : annotez un paramètre avec `Resolve(ask_quantity)`, où `ask_quantity` est une fonction ordinaire que vous écrivez, et le SDK pose la question par le mécanisme que la connexion prend en charge, une requête d’élicitation en direct sur une session historique ou une requête à plusieurs allers-retours en 2026. Un seul corps d’outil, les deux générations : -```python title="dual_era.py" hl_lines="24 37-38" +```python title="server.py" hl_lines="21" --8<-- "docs_src/legacy_clients/tutorial001.py" ``` -Ce fichier résume tout l’argument en un seul endroit : un serveur, un outil adossé à `Resolve`, et un client historique plus un client moderne qui obtiennent tous deux leur réponse, en mémoire. **[Requêtes à plusieurs allers-retours](handlers/multi-round-trip.md)** explique le mécanisme (y compris `request_state`, que le SDK scelle et vérifie pour vous) ; **[Élicitation](handlers/elicitation.md)** couvre la façon de poser la question. +```python title="client.py" hl_lines="14-15" +--8<-- "docs_src/legacy_clients/tutorial001_client.py" +``` + +Ces deux fichiers résument tout l’argument : un serveur, un outil adossé à `Resolve`, et un client historique plus un client moderne qui obtiennent tous deux leur réponse du même serveur en cours d’exécution (**[Prendre en charge les clients historiques](run/legacy-clients.md)** les passe en revue). **[Requêtes à plusieurs allers-retours](handlers/multi-round-trip.md)** explique le mécanisme (y compris `request_state`, que le SDK scelle et vérifie pour vous) ; **[Élicitation](handlers/elicitation.md)** couvre la façon de poser la question. !!! warning "C’est le seul endroit où un serveur v1 porté change de comportement" Vos propres tests y butent en premier : `Client(mcp)` négocie par défaut 2026-07-28 avec votre diff --git a/i18n/hi/pages/advanced/apps.md b/i18n/hi/pages/advanced/apps.md index 13361d90..efaea6c3 100644 --- a/i18n/hi/pages/advanced/apps.md +++ b/i18n/hi/pages/advanced/apps.md @@ -1,6 +1,6 @@ --- translation: - sections: [0355618e5f4d5fe4, 1821eaf50f2d0b64, 82e0b28ebd3abf5a, 8ac39614c094f2d0, dab6ff945501ab2a, bd5565c3b2d4f959, 96819ce3d63a0487] + sections: [0355618e5f4d5fe4, 0fefa31fb7c7b585, 5c53e7487c9c70cc, 8ac39614c094f2d0, dab6ff945501ab2a, bd5565c3b2d4f959, 96819ce3d63a0487] tool: 1 --- # MCP Apps {#mcp-apps} @@ -18,7 +18,7 @@ SDK इसे built-in `Apps` extension (`io.modelcontextprotocol/ui`) के ## चेहरे वाली घड़ी {#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" ``` @@ -49,15 +49,37 @@ model `content` पढ़ता है; iframe इंसानों के ल model को देता है, और text-only client को **सिर्फ़** वही मिलता है। इसलिए मानक pattern है: एक tool, दो जवाब। `get_time` को फिर से देखें: -```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)` तभी `True` होता है जब client ने `io.modelcontextprotocol/ui` extension declare किया हो **और** अपनी `mimeTypes` settings में `text/html;profile=mcp-app` सूचीबद्ध किया हो। यह field ज़रूरी है, -इसलिए जो client इसे छोड़ देता है वह गिना नहीं जाता। इसी file में `main()` ठीक यही -declare करता है: negotiation का client वाला आधा हिस्सा, और rich जवाब वापस आता है। +इसलिए जो client इसे छोड़ देता है वह गिना नहीं जाता। negotiation का client वाला +आधा हिस्सा यह रहा: + +```python title="client.py" hl_lines="8 12" +--8<-- "docs_src/apps/tutorial001_client.py" +``` + +`server.py` को HTTP पर serve करें, फिर दूसरे terminal से client चलाएँ: + +```console +uv run mcp run server.py --transport streamable-http +``` + +```console +python client.py +``` + +```text +2026-06-26T12:00:00Z +``` + +rich जवाब वापस आया। `Client` call से `extensions=[APPS_SUPPORT]` हटा दें, तो यही +program इसकी जगह `The time is 2026-06-26T12:00:00Z.` print करता है, और text-only +client को हमेशा बस इतना ही दिखता है। !!! warning कभी भी `"[Rendered UI]"` जैसा placeholder अकेले content के रूप में न लौटाएँ। diff --git a/i18n/hi/pages/advanced/extensions.md b/i18n/hi/pages/advanced/extensions.md index 3617649c..3b4366f0 100644 --- a/i18n/hi/pages/advanced/extensions.md +++ b/i18n/hi/pages/advanced/extensions.md @@ -1,6 +1,6 @@ --- translation: - sections: [05891e7cc1938a13, b3c01a6af28c51ee, 7ffc91f5e38bdfe0, 717d3f235a8333a7, f471a13b2fe5d737, ed6af2df4b656dff] + sections: [05891e7cc1938a13, b3c01a6af28c51ee, 6eba6ce094f4417e, 125f28588c85dd7b, ddd8969b698ca11c, ed6af2df4b656dff] tool: 1 --- # Extensions {#extensions} @@ -49,7 +49,7 @@ prefix के रूप में ऐसा domain इस्तेमाल क सबसे छोटा काम का extension एक tool और एक 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" ``` @@ -57,17 +57,23 @@ prefix के रूप में ऐसा domain इस्तेमाल क * `settings()` वह value है जो `capabilities.extensions["com.example/stamps"]` पर advertise होती है। बिना settings के extension advertise करने के लिए `{}` (default) लौटाएँ। * extension को server कभी नहीं मिलता। यह अपने योगदान data के रूप में declare करता है; `MCPServer` उन्हें consume करता है। mutate करने के लिए कोई `self.server` नहीं है। -और `main()` इसका सबूत है, सीधे `mcp` से जुड़ा एक in-memory client: +इसे HTTP पर serve करें, और एक client इसका सबूत है: -```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" +``` + +इस page की हर `server.py` इसी command से serve होती है, और हर `client.py` उसके साथ-साथ दूसरे terminal से `python client.py` से चलती है। + ### अपने methods serve करना {#serving-your-own-methods} extension **नए request methods** register कर सकता है: उसके अपने verbs, जो spec के verbs के साथ-साथ serve होते हैं: -```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" ``` @@ -83,14 +89,15 @@ methods **सख़्ती से additive** हैं। SDK इसे constr ### Client side {#the-client-side} -उसी file का `main()` ही client की पूरी कहानी है, उसके दोनों हिस्से: +client अपना अलग program है, और client की कहानी के दोनों हिस्से इसी में हैं: -```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)])` extension declare करता है। ये declarations `ClientCapabilities.extensions` बन जाती हैं: 2026-07-28 connection पर यह map हर request के `_meta` envelope में जाता है, इसलिए server इसे **हर** request पर देखता है; legacy connection पर यह `initialize` handshake के साथ जाता है। server code को फ़र्क नहीं पड़ता कि कौन सा: `require_client_extension(ctx, ...)` और `ctx.session.check_client_capability(...)` दोनों रास्तों पर सही स्रोत पढ़ते हैं। * vendor methods एक परत नीचे `client.session.send_request(...)` पर उतरते हैं; `Client` सिर्फ़ spec verbs के लिए first-class methods जोड़ता है। `send_request` कोई भी `Request` subclass स्वीकार करता है, इसलिए vendor request जैसी है वैसी ही चली जाती है। +* `SearchRequest` और उसके साथ जाने वाले दो models extension का wire contract हैं, इसलिए client उन्हें अपने लिए खुद declare करता है। published extension इन्हें ऐसे package में देगा जिसे दोनों पक्ष import करें। ### `tools/call` को intercept करना {#intercepting-toolscall} @@ -109,12 +116,18 @@ hook `tools/call` को wrap करता है, और कुछ नहीं ## client extension इस्तेमाल करना {#using-a-client-extension} -**client extension** वही contract है, इस्तेमाल करने वाले पक्ष से: एक identifier के पीछे client-side behaviour का bundle। instances को `Client(extensions=[...])` में पास करें और tools सामान्य तरीके से call करें: +**client extension** वही contract है, इस्तेमाल करने वाले पक्ष से: एक identifier के पीछे client-side behaviour का bundle। यहाँ server `buy` का जवाब सामान के बजाय redeem करने लायक receipt से देता है, और सिर्फ़ उसी client को जिसने extension declare किया हो: -```python title="client.py" hl_lines="66-68" +```python title="server.py" hl_lines="22-25" --8<-- "docs_src/extensions/tutorial006.py" ``` +client पर, instances को `Client(extensions=[...])` में पास करें और tools सामान्य तरीके से call करें: + +```python title="client.py" hl_lines="33-35" +--8<-- "docs_src/extensions/tutorial006_client.py" +``` + `call_tool("buy", ...)` हर दूसरे call की तरह सादा `CallToolResult` लौटाता है। extension ने जो बदला: server अब `buy` का जवाब final result के बजाय `receipt` **result shape** से दे सकता है, और `call_tool` के लौटने से पहले `Receipts` उसे पूरा कर देता है (यहाँ follow-up call से receipt redeem करके)। call site में कुछ नहीं हिलता। extension हटा दें तो इनमें से कुछ भी मौजूद नहीं: server का gate उस client को मना कर देता है जिसने इसे declare नहीं किया (error -32021), और gate छोड़ने वाले server से आया claimed shape validation में fail होता है, ठीक वैसे जैसे spec अनजान `resultType` के लिए माँगता है। default रूप से बंद, wire के दोनों सिरों पर। @@ -124,15 +137,15 @@ extension हटा दें तो इनमें से कुछ भी म ```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")]) ``` ## client extension लिखना {#writing-a-client-extension} `ClientExtension` को subclass करें और सिर्फ़ वही override करें जिसकी ज़रूरत हो। योगदान के तीन प्रकार, हर एक का default: `settings()`, `claims()` और `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" ``` * identifier वही grammar मानता है जो server का, और class define होते ही validate होता है। @@ -153,12 +166,18 @@ handler को validated params एक-एक करके, dispatch के क ### Extension verbs {#extension-verbs} -extension के अपने request methods को client-side registration की ज़रूरत नहीं। vendor request type `mcp.types.Request` को subclass करता है और `client.session.send_request` से जाता है, जैसा [अपने methods serve करना](#serving-your-own-methods) में है। एक बात और: जब किसी params key का `Mcp-Name` header में जाना ज़रूरी हो (tasks जैसे extension specs अपने verbs के लिए यह माँगते हैं), तो request type `name_param` declare करता है: +extension के अपने request methods को client-side registration की ज़रूरत नहीं। vendor request type `mcp.types.Request` को subclass करता है और `client.session.send_request` से जाता है, जैसा [अपने methods serve करना](#serving-your-own-methods) में है। ऐसा server लें जिसका extension एक named job के बारे में एक verb serve करता है: -```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" ``` +client पर एक बात और जुड़ती है: जब किसी params key का `Mcp-Name` header में जाना ज़रूरी हो (tasks जैसे extension specs अपने verbs के लिए यह माँगते हैं), तो request type `name_param` declare करता है: + +```python title="client.py" hl_lines="20-23 28-29" +--8<-- "docs_src/extensions/tutorial007_client.py" +``` + session हर send path पर `params["jobId"]` को `Mcp-Name` में mirror करता है, और value न होने पर ज़रूरी header चुपचाप छोड़ने के बजाय साफ़ तौर पर fail होता है। ## extension क्या नहीं कर सकता {#what-an-extension-cannot-do} diff --git a/i18n/hi/pages/advanced/low-level-server.md b/i18n/hi/pages/advanced/low-level-server.md index d2032b78..90f0b3a1 100644 --- a/i18n/hi/pages/advanced/low-level-server.md +++ b/i18n/hi/pages/advanced/low-level-server.md @@ -1,6 +1,6 @@ --- translation: - sections: [2c79b6338e09b7ac, 7edc43b3fae11314, 1086e77ce561cd7f, a3f71823df5efc31, 9fc7109f72201cae, d50fe7faead8cf68, 7bf25983df655b66, 6330e1f4c6029683, 2f1749c8c133fa1c, 8db7116fc8ddd0ee, ebc33704fbd74262, cd0e9c933350390e] + sections: [2c79b6338e09b7ac, 9d5d10a5f0405d0a, 1086e77ce561cd7f, a3f71823df5efc31, 9fc7109f72201cae, d50fe7faead8cf68, 7bf25983df655b66, 6330e1f4c6029683, 2f1749c8c133fa1c, 8db7116fc8ddd0ee, ebc33704fbd74262, 0fde3bcea081ba3a] tool: 1 --- # Low-level Server {#the-low-level-server} @@ -25,9 +25,9 @@ translation: तीन चीज़ें बदलीं, और पूरा low-level API बस यही है: -* **Handlers constructor parameters हैं।** `on_list_tools=` और `on_call_tool=` `Server(...)` में जाते हैं। यहाँ नीचे कोई decorator नहीं है, और हर handler का आकार एक ही है: `async (ctx, params) -> result`। -* **Input schema आप लिखते हैं।** `Tool.input_schema` एक सादा JSON Schema `dict` है। कोई इसे type hints से नहीं निकालता, क्योंकि निकालने के लिए type hints हैं ही नहीं। -* **Result आप बनाते हैं।** `CallToolResult(content=[TextContent(...)])`, हाथ से। न कुछ wrap होता है, न convert, न return annotation से अनुमान लगाया जाता है। +* **handlers constructor parameters हैं।** `on_list_tools=` और `on_call_tool=` `Server(...)` में जाते हैं। यहाँ नीचे कोई decorator नहीं है, और हर handler का आकार एक ही है: `async (ctx, params) -> result`। +* **input schema आप लिखते हैं।** `Tool.input_schema` एक सादा JSON Schema `dict` है। कोई इसे type hints से नहीं निकालता, क्योंकि निकालने के लिए type hints हैं ही नहीं। +* **result आप बनाते हैं।** `CallToolResult(content=[TextContent(...)])`, हाथ से। न कुछ wrap होता है, न convert, न return annotation से अनुमान लगाया जाता है। `params` parse की हुई request है: `CallToolRequestParams` आपको `.name` और `.arguments` देता है। `ctx` एक `ServerRequestContext` है: client से वापस बात करने के लिए `ctx.session`, `ctx.lifespan_context`, `ctx.request_id`, और `ctx.meta`, यानी request का आने वाला `_meta`। @@ -36,18 +36,22 @@ translation: ### इसे आज़माएँ {#try-it} -इसके लिए कोई Inspector नहीं है: `mcp dev` और `mcp run` सिर्फ़ `MCPServer` स्वीकार करते हैं। In-memory `Client` को कोई फ़र्क नहीं पड़ता; वह low-level `Server` को ठीक वैसे ही लेता है जैसे `MCPServer` को: +`mcp dev` और `mcp run` सिर्फ़ `MCPServer` स्वीकार करते हैं, इसलिए इसे serve आप खुद करते हैं। `server.py` की आख़िरी line इससे एक साधारण ASGI app बनाती है, और uvicorn उसे चलाता है: -```python title="main.py" +```console +uvicorn server:app --port 8000 +``` + +Inspector को, या किसी भी client को, `http://localhost:8000/mcp` पर point करें: + +```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) @@ -61,8 +65,10 @@ asyncio.run(main()) वही text जो `@mcp.tool()` वाले version ने दिया था। दो असली अंतर: -* `result.structured_content` `None` है। High-level server आपके लिए `-> str` को `{"result": ...}` में wrap कर देता है; यहाँ जो आपने नहीं बनाया, उसे कोई नहीं बनाता। -* `list_tools` वही schema लौटाता है जो **आपने** type किया, अक्षर-दर-अक्षर। High-level version में हर property पर `"title": "Query"` था और root पर `"title": "search_booksArguments"`: Pydantic की देन। यहाँ नीचे, अगर कुछ wire पर है, तो उसे वहाँ आपने रखा है। +* `result.structured_content` `None` है। high-level server आपके लिए `-> str` को `{"result": ...}` में wrap कर देता है; यहाँ जो आपने नहीं बनाया, उसे कोई नहीं बनाता। +* `list_tools` वही schema लौटाता है जो **आपने** type किया, अक्षर-दर-अक्षर। high-level version में हर property पर `"title": "Query"` था और root पर `"title": "search_booksArguments"`: Pydantic की देन। यहाँ नीचे, अगर कुछ wire पर है, तो उसे वहाँ आपने रखा है। + +test में आप uvicorn और port दोनों छोड़ देते हैं: `Client(server)` low-level `Server` को in-process ठीक वैसे ही लेता है जैसे `MCPServer` को, और **[Testing](../get-started/testing.md)** यही pattern है। ## आपके लिए कुछ जाँचा नहीं जाता {#nothing-is-checked-for-you} @@ -207,12 +213,12 @@ Handshake runner का है। `server/discover`, `ping`, और बाकी ## सारांश {#recap} -* Low-level `Server` अपने handlers `on_*` **constructor parameters** के रूप में लेता है; हर handler `async (ctx, params) -> result` है। +* low-level `Server` अपने handlers `on_*` **constructor parameters** के रूप में लेता है; हर handler `async (ctx, params) -> result` है। * `input_schema` dict आप लिखते हैं और `CallToolResult` आप बनाते हैं। आपके लिए न कुछ derive होता है, न wrap, न validate। -* Handler में exception `-32603` protocol error है। जिस tool error को model पढ़ सके, वह `is_error=True` वाला `CallToolResult` है जिसे **आप** लौटाते हैं। -* Result पर `_meta` client application के नाम है, model के नहीं। +* handler में exception `-32603` protocol error है। जिस tool error को model पढ़ सके, वह `is_error=True` वाला `CallToolResult` है जिसे **आप** लौटाते हैं। +* result पर `_meta` client application के नाम है, model के नहीं। * `Server[T]` उस चीज़ में generic है जो उसका lifespan yield करता है; `ctx.lifespan_context` एक typed `T` है। * `add_request_handler(method, params_type, handler)` कोई भी method serve करता है। `initialize` reserved है। * `Server` जो capabilities advertise करता है, वे इससे निकलती हैं कि आपने कौन से handlers register किए। -`Client(server)` ने दोनों servers के साथ एक जैसा बर्ताव किया क्योंकि वे एक ही protocol **हैं**, और यही असली बात है। इससे नीचे की अगली layer कोई class है ही नहीं: वह **[Middleware](middleware.md)** है। +client ने दोनों servers के साथ एक जैसा बर्ताव किया क्योंकि वे एक ही protocol **हैं**, और यही असली बात है। इससे नीचे की अगली layer कोई class है ही नहीं: वह **[Middleware](middleware.md)** है। diff --git a/i18n/hi/pages/advanced/pagination.md b/i18n/hi/pages/advanced/pagination.md index 31096406..6bd3fa88 100644 --- a/i18n/hi/pages/advanced/pagination.md +++ b/i18n/hi/pages/advanced/pagination.md @@ -1,6 +1,6 @@ --- translation: - sections: [a9aba7a026c7bd85, ed32bda7ba9ae33a, 7e64cc5646abb91f, 22a0129ee78b3c63, d875373c06d8d2f9] + sections: [a9aba7a026c7bd85, 83e2a08b9d46a398, 9fd8a0aa384b3257, 22a0129ee78b3c63, d875373c06d8d2f9] tool: 1 --- # Pagination {#pagination} @@ -31,9 +31,13 @@ Pagination उस server के लिए है जिसकी resource list ### इसे आज़माएँ {#try-it} -`Client(server)` memory में low-level `Server` से ठीक वैसे ही जुड़ता है जैसे `MCPServer` से। +`mcp run` सिर्फ़ `MCPServer` स्वीकार करता है, इसलिए इसे आप ख़ुद serve करते हैं। `server.py` की आख़िरी line `Server` से एक साधारण ASGI app बनाती है, और uvicorn उसी को चलाता है: -बिना arguments के `list_resources()` call करें। आपको दस resources मिलते हैं, `book-1` से `book-10` तक, और `next_cursor` string `"10"` है। +```console +uvicorn server:app --port 8000 +``` + +किसी भी client (**[The Client](../client/index.md)**, या Inspector) को `http://localhost:8000/mcp` की ओर point करें और बिना arguments के `list_resources()` call करें। आपको दस resources मिलते हैं, `book-1` से `book-10` तक, और `next_cursor` string `"10"` है। इसे `list_resources(cursor="10")` से वापस दें, तो पहला resource `book-11` है और नया `next_cursor` `"20"` है। @@ -43,7 +47,7 @@ Pagination उस server के लिए है जिसकी resource list `Client` का हर `list_*` method (`list_tools`, `list_resources`, `list_resource_templates`, `list_prompts`) `cursor=` keyword लेता है। Paged list को पूरा खींचना एक `while True` है: -```python title="client.py" hl_lines="26-32" +```python title="client.py" hl_lines="9-15" --8<-- "docs_src/pagination/tutorial002.py" ``` @@ -51,7 +55,7 @@ Pagination उस server के लिए है जिसकी resource list * `next_cursor` देखने से **पहले** extend करें: आख़िरी page में भी resources होते हैं। * `next_cursor is None` ही बाहर निकलने का रास्ता है। बाकी कुछ भी सीधे `cursor=` में वापस जाता है, बिना छेड़े। -इसका `main()` चलाएँ और यह `100 resources` print करता है: दस-दस के दस pages, एक ऐसे loop से जुड़े हुए जिसे कभी पता ही नहीं था कि दस pages थे। +uvicorn अब भी `server.py` serve कर रहा हो, तब दूसरे terminal में `python client.py` चलाएँ। यह `100 resources` print करता है: दस-दस के दस pages, एक ऐसे loop से जुड़े हुए जिसे कभी पता ही नहीं था कि दस pages थे। यह वही loop है जो **[The Client](../client/index.md)** हर `list_*` verb के लिए दिखाता है, और paging न करने वाले server पर इसकी कोई क़ीमत नहीं: पहले ही response में `next_cursor` `None` होता है और loop एक बार चलता है। diff --git a/i18n/hi/pages/client/caching.md b/i18n/hi/pages/client/caching.md index dd239cb2..e22005c4 100644 --- a/i18n/hi/pages/client/caching.md +++ b/i18n/hi/pages/client/caching.md @@ -1,6 +1,6 @@ --- translation: - sections: [9e7b9a1710e5aeba, b74ca4c1d2ddddee, fa8714e61bf90c5a, 04db67a886b7271c, 857690fb8f876800] + sections: [9e7b9a1710e5aeba, 66a2e9acc9101d54, d3d25aa802f5145a, 04db67a886b7271c, 857690fb8f876800] tool: 1 --- # Caching hints {#caching-hints} @@ -30,7 +30,7 @@ server खुद कुछ भी cache नहीं करता। ये fiel low-level `Server` पर handlers अपने results खुद बनाते हैं, और `ttl_ms` / `cache_scope` result models पर बस fields हैं। जो handler इन्हें explicitly set करता है, वह constructor map पर हमेशा भारी पड़ता है, field दर field: -```python title="server.py" hl_lines="10 16" +```python title="server.py" hl_lines="11 17" --8<-- "docs_src/caching/tutorial002.py" ``` @@ -44,10 +44,24 @@ paginated lists पर एक सावधानी: protocol की माँ 2026-07-28 session पर `Client` आपके लिए hints का पालन करता है: इसमें built-in response cache है, जो default रूप से चालू रहता है। जो result `ttlMs` के साथ आता है, वह store हो जाता है, और उस TTL के भीतर वैसा ही call cache से serve होता है, बिना round trip के। जिस result में **कोई** hint नहीं होता, वह cache नहीं होता: बिना hint वाले results को `CacheConfig.default_ttl_ms` मिलता है, जिसका default `0` है (तुरंत stale), इसलिए जो server कुछ भी declare नहीं करता, उसे ठीक वैसा ही call-दर-call traffic दिखता है जैसा हमेशा दिखता था। -```python title="client.py" hl_lines="33 35 38" +इसे होते हुए देखने के लिए, पिछले section की `server.py` को uvicorn से serve करें (उसकी आखिरी line ASGI app बनाती है)। handler जब भी सच में चलता है, एक line print करता है: + +```console +uvicorn server:app --port 8000 +``` + +```python title="client.py" hl_lines="20 23 28" --8<-- "docs_src/caching/tutorial003.py" ``` +दूसरे terminal से `python client.py` चलाएँ। यह वे hints print करता है जो पहले result में आए थे, handler का `ttlMs` और उसके बगल में map का `cacheScope`: + +```text +1000 public +``` + +बाकी कहानी server का terminal बताता है: uvicorn के request logs के बीच `tools/list served` तीन बार दिखता है। + चार calls, तीन fetches। दूसरे call को fresh entry मिली और वह server तक पहुँचा ही नहीं; (inject की गई) clock को TTL से आगे बढ़ाने पर तीसरे ने फिर से fetch किया; चौथे ने `cache_mode="refresh"` कहा। यह kwarg पाँचों caching verbs पर मौजूद है (`list_tools`, `list_prompts`, `list_resources`, `list_resource_templates`, `read_resource`): * `"use"` (default) fresh entry हो तो उसे serve करता है, और न हो तो fetch करके store करता है। @@ -56,7 +70,7 @@ paginated lists पर एक सावधानी: protocol की माँ एक नियम `"use"` से ऊपर है: **`meta` वाले calls हमेशा server तक पहुँचते हैं।** जिस request में `meta` set हो (progress token, tracing fields), उसे wire request की उम्मीद होती है, इसलिए `cache_mode="use"` में उसे `"refresh"` माना जाता है: cache read छोड़ दिया जाता है, और fetch किया गया result फिर भी cache की entry की जगह ले लेता है। `"bypass"` और explicit `"refresh"` हमेशा की तरह ही बर्ताव करते हैं। -caching पूरी तरह बंद करने के लिए `Client(server, cache=None)` से construct करें: हर call फिर से round trip है, और `cache_mode`, भले ही अब भी स्वीकार होता है, कुछ नहीं करता। +caching पूरी तरह बंद करने के लिए `Client` construct करते समय `cache=None` pass करें: हर call फिर से round trip है, और `cache_mode`, भले ही अब भी स्वीकार होता है, कुछ नहीं करता। scope का पालन भी अपने आप होता है: `"private"` entries cache के *partition* (नीचे देखें) से बँधी होती हैं, जबकि `"public"` वाली चाहें तो ज़्यादा व्यापक sharing चुन सकती हैं। और जिन entries का नाम notifications लेते हैं, ठीक उनके लिए **notifications TTL पर भारी पड़ते हैं**: `list_changed` notification मेल खाती cached listing को evict कर देता है, और `resources/updated` ठीक उसी URI के तहत store किए गए cached read को evict करता है, चाहे वे कितने भी fresh रहे हों। 2026-07-28 connection पर ये notifications `subscriptions/listen` stream पर आते हैं जिसे आप `client.listen(...)` से खोलते हैं, और eviction आपके watcher को event दिखने से पहले पूरा हो जाता है; **[Subscriptions](subscriptions.md)** वही page है। diff --git a/i18n/hi/pages/client/callbacks.md b/i18n/hi/pages/client/callbacks.md index c7f70ccf..6d3ff282 100644 --- a/i18n/hi/pages/client/callbacks.md +++ b/i18n/hi/pages/client/callbacks.md @@ -1,6 +1,6 @@ --- translation: - sections: [adf3c545b5be46b6, 916cd3ab1c03f461, e9be7a8d0eb0a456, 565890a636288ecf, 6af7e49db9129ec3, 06b0238c174186af, 90c6043be435fcb0] + sections: [adf3c545b5be46b6, 916cd3ab1c03f461, 32ef568335dd95a7, 565890a636288ecf, 6af7e49db9129ec3, 06b0238c174186af, 0abc5ea5cb7ff6b3] tool: 1 --- # Client callbacks {#client-callbacks} @@ -63,7 +63,7 @@ result.content # [TextContent(type='text', text='Card issued to Ada Lovelace.') `Client(...)` call पर `mode="legacy"` असल में काम कर रहा है। default रूप से `Client(...)` modern protocol path negotiate करता है, और उस path में server-से-client requests के लिए कोई back-channel नहीं है: आपका callback चलने से पहले ही `ctx.elicit` fail हो जाता है। यह transport तय नहीं करता; negotiated - protocol तय करता है, in-memory में भी और URL पर भी। जब भी आपके client को ऐसी किसी request का जवाब देना हो, + protocol तय करता है। जब भी आपके client को ऐसी किसी request का जवाब देना हो, `mode="legacy"` तय करें; इस page के पीछे का हर test यही करता है। पूरी जानकारी **[Protocol versions](../protocol-versions.md)** में है। 2026-07-28 session पर callback बेकार नहीं होता, उसे input अलग तरीके से मिलता है: जब कोई tool @@ -149,6 +149,6 @@ result.structured_content # {'result': ['elicitation']} * **callback register करना ही capability घोषित करना है।** इसके बिना SDK आपकी ओर से server की request ठुकरा देता है और पूरा call `MCPError` के साथ fail हो जाता है। * server पूछने से पहले `ctx.session.check_client_capability(...)` से पता कर लेता है। * `sampling_callback` और `list_roots_callback` इसी तरह काम करते हैं लेकिन deprecated features को serve करते हैं; modern servers इनकी जगह multi-round-trip requests इस्तेमाल करते हैं। -* `logging_callback` और `message_handler` को notifications मिलती हैं। वे कुछ घोषित नहीं करते। +* `logging_callback` और `message_handler` को notifications मिलते हैं। वे कुछ घोषित नहीं करते। -`Client(...)` का पहला argument transport object है। हर प्रकार की जानकारी **[Client transports](transports.md)** में है। +`Client(...)` का पहला argument transport चुनता है। हर प्रकार की जानकारी **[Client transports](transports.md)** में है। diff --git a/i18n/hi/pages/client/index.md b/i18n/hi/pages/client/index.md index bc2a393f..eb85cb86 100644 --- a/i18n/hi/pages/client/index.md +++ b/i18n/hi/pages/client/index.md @@ -1,6 +1,6 @@ --- translation: - sections: [ebef1e7a0df854f4, 8355cfaf1f76c9d5, 8e79141fc2985342, 46bdb07c7537e8a5, 80ce41579825a6fa, 5f0fa90494de8f65, 83d10514eaa62fa5, 9190555aa39a5d28, 84a4c9d8bf14dddb, 927d71cf40b58c30] + sections: [ebef1e7a0df854f4, 7e16449f66e7dfd6, eeb0682f7d2a1079, 5713f0196a34e6e7, 0e844597859e4248, 3a97d9195ddcc92e, 1da08c483e59c141, 84702cc6e0a1fd42, 8dee7a31c86ffc5c, 83a5bce168ef23d7] tool: 1 --- # Client {#the-client} @@ -11,13 +11,23 @@ translation: ## आपका पहला client {#your-first-client} -```python title="client.py" hl_lines="14-18" +client को बात करने के लिए server चाहिए। इस page का हर उदाहरण इसी Bookshop से connect करता है। इसे `server.py` के नाम से save करें और HTTP पर चलता छोड़ दें: + +```python title="server.py" --8<-- "docs_src/client/tutorial001.py" ``` -ऊपर वाला server सिर्फ़ इसलिए है ताकि connect करने के लिए कुछ हो। client वे पाँच highlighted lines हैं। +```console +uv run mcp run server.py --transport streamable-http +``` -* `Client(mcp)` को **server object ही** दिया गया है। यही in-memory transport है: न subprocess, न port, न HTTP। इस page का हर उदाहरण, और आपका लिखा हर test, इसी तरह connect करता है। +इससे server `http://localhost:8000/mcp` पर serve होता है। client अपना अलग program है। इसे `client.py` के नाम से save करें और दूसरे terminal में `python client.py` चलाएँ: + +```python title="client.py" hl_lines="7-11" +--8<-- "docs_src/client/tutorial001_client.py" +``` + +* `Client("http://localhost:8000/mcp")` को **URL** दिया गया है, इसलिए यह अभी शुरू किए गए server से Streamable HTTP पर connect करता है। * `async with` ही **lifecycle** है। इसमें enter करते ही connect और negotiate होता है; बाहर निकलते ही disconnect। कोई `connect()` / `close()` जोड़ी नहीं है, और block खत्म होने के बाद `Client` दोबारा इस्तेमाल नहीं हो सकता। * block के अंदर connection की जानकारी पहले से सादी properties के रूप में मौजूद है। @@ -25,10 +35,10 @@ translation: `Client` एक positional argument लेता है और उसके type से transport तय करता है: -* `MCPServer` (या low-level `Server`) instance: **in-process** connect होता है। -* URL string (`Client("http://localhost:8000/mcp")`): Streamable HTTP, production वाला रास्ता। -* `StdioServerParameters`: वह command जो **subprocess** के रूप में launch होता है, और जिससे उसके stdin और stdout के ज़रिए बात होती है। +* URL string (`Client("http://localhost:8000/mcp")`): Streamable HTTP, वह transport जिसके पीछे आप deploy करते हैं। +* `StdioServerParameters`: वह command जो local **subprocess** के रूप में launch होता है, और जिससे उसके stdin और stdout के ज़रिए बात होती है। * **transport**: कोई भी चीज़ जिसे आप `async with ... as (read, write)` कर सकें, जैसे आपके अपने HTTP client के ऊपर `streamable_http_client(url, http_client=...)`। +* `MCPServer` (या low-level `Server`) instance: **in-process** connect होता है, न subprocess, न port। यह tests के लिए है, और **[Testing](../get-started/testing.md)** इसी पर आगे बढ़ता है। इस page की बाकी हर चीज़ चारों में एक जैसी है। Headers, subprocesses, timeouts और `Transport` protocol का अपना अलग page है: **[Client transports](transports.md)**। @@ -49,11 +59,11 @@ translation: ## tools की सूची लेना {#listing-tools} -```python title="client.py" hl_lines="15-20" +```python title="client.py" hl_lines="8-13" --8<-- "docs_src/client/tutorial002.py" ``` -`list_tools()` एक `ListToolsResult` लौटाता है; tools `.tools` में हैं। हर एक वह पूरी definition है जो host किसी model को देगा: +`list_tools()` एक `ListToolsResult` लौटाता है; tools `.tools` में हैं। हर एक वह पूरी definition है जो host किसी model को देगा। यह पहला है: ```python tool.name # 'search_books' @@ -77,6 +87,8 @@ tool.description # 'Search the catalog by title or author.' UI को argument form दिखाने के लिए, और model को valid arguments बनाने के लिए, जो कुछ चाहिए वह सब इसी schema में है। +दूसरा tool, `lookup_book`, बिना `title=` के register हुआ था, इसलिए उसका `tool.title` `None` है। + !!! tip `title` optional है, इसलिए किसी इंसान को tools दिखाने वाले UI को चुनना पड़ता है: `title` हो तो वही, नहीं तो `name`। `from mcp.shared.metadata_utils import get_display_name` ठीक यही करता है, @@ -86,7 +98,7 @@ UI को argument form दिखाने के लिए, और model को `call_tool(name, arguments)` tool चलाता है और आपको `CallToolResult` वापस देता है। -```python title="client.py" hl_lines="27-34" +```python title="client.py" hl_lines="9-16" --8<-- "docs_src/client/tutorial003.py" ``` @@ -142,7 +154,7 @@ result.is_error # False resource verbs जोड़ियों में आते हैं: सूची लेने के दो तरीके, पढ़ने का एक। -```python title="client.py" hl_lines="22-31" +```python title="client.py" hl_lines="9-18" --8<-- "docs_src/client/tutorial004.py" ``` @@ -156,7 +168,7 @@ client को यह भी बताया जा सकता है कि ## Prompts {#prompts} -```python title="client.py" hl_lines="15-20" +```python title="client.py" hl_lines="8-13" --8<-- "docs_src/client/tutorial005.py" ``` @@ -181,7 +193,7 @@ host ये messages सीधे model को दे देता है। प जिस server में completion handler हो वह user के type करते-करते prompt और resource-template arguments autocomplete कर सकता है। -```python title="client.py" hl_lines="27-31" +```python title="client.py" hl_lines="9-13" --8<-- "docs_src/client/tutorial006.py" ``` @@ -194,21 +206,21 @@ host ये messages सीधे model को दे देता है। प हर `list_*` method एक `cursor=` keyword लेता है और हर result में `next_cursor` होता है। जब `next_cursor` `None` हो, आपके पास सब कुछ है। -```python title="client.py" hl_lines="22-30" +```python title="client.py" hl_lines="7-15" --8<-- "docs_src/client/tutorial007.py" ``` -यह loop हर server के साथ सही है। `MCPServer` सब कुछ एक ही page में लौटाता है, इसलिए `next_cursor` `None` होता है और loop एक बार चलता है, यही वजह है कि ज़्यादातर code इसे कभी लिखता ही नहीं। जो servers सच में page करते हैं, और cursors जिन नियमों का पालन करते हैं, वे **[Pagination](../advanced/pagination.md)** में हैं। +`list_all_tools` हर server के साथ सही है। `MCPServer` सब कुछ एक ही page में लौटाता है, इसलिए `next_cursor` `None` होता है और loop एक बार चलता है, यही वजह है कि ज़्यादातर code इसे कभी लिखता ही नहीं। जो servers सच में page करते हैं, और cursors जिन नियमों का पालन करते हैं, वे **[Pagination](../advanced/pagination.md)** में हैं। ## tests में {#in-tests} -बिना process और बिना port वाला `Client(mcp)` अपने आप में server के लिए test harness है। +इस page की हर `client.py` HTTP के ज़रिए `server.py` तक पहुँची। test में आप network छोड़ देते हैं और `Client` को server object ही दे देते हैं: `from server import mcp`, फिर `Client(mcp)`। न process, न port, और ऊपर का हर method वैसे ही काम करता है। -इसी के लिए एक constructor flag बना है: `Client(mcp, raise_exceptions=True)`। इसका असर सिर्फ़ in-memory connections पर होता है, और **[Testing](../get-started/testing.md)** वह page है जो इसे समझाता है और इसके चारों ओर पूरा pattern बनाता है। +इसी के लिए एक constructor flag बना है: `Client(mcp, raise_exceptions=True)`। इसका असर सिर्फ़ in-process connections पर होता है, और **[Testing](../get-started/testing.md)** वह page है जो इसे समझाता है और इसके चारों ओर पूरा pattern बनाता है। ## सारांश {#recap} -* `Client(x)` server object से in-memory connect होता है, URL string से Streamable HTTP पर, और बाकी किसी भी चीज़ से transport के ज़रिए। +* `Client(x)` URL string से Streamable HTTP पर connect होता है, `StdioServerParameters` के लिए subprocess launch करता है, transport में सीधे enter करता है, और tests में server object ही ले लेता है। * `async with` ही पूरा lifecycle है। इसके अंदर `server_capabilities` और `protocol_version` पहले से भरे होते हैं; server दे तो `server_info` और `instructions` भी। * `list_tools()` आपको हर tool का `name`, `title`, `description` और `input_schema` देता है। * `call_tool()` model के लिए `content`, आपके code के लिए `structured_content`, और `is_error` लौटाता है। raise करने वाला tool एक result है, exception नहीं। diff --git a/i18n/hi/pages/client/oauth-clients.md b/i18n/hi/pages/client/oauth-clients.md index 51526eb2..bc31d4ce 100644 --- a/i18n/hi/pages/client/oauth-clients.md +++ b/i18n/hi/pages/client/oauth-clients.md @@ -1,6 +1,6 @@ --- translation: - sections: [c6899d3892bd9fa0, 79372cff3cc48a88, 63878d29e87c3e73, 13175843d3588af4, e7e2b9fd516f77de, 758f06399b513c1f, a05d7278487d610b] + sections: [c6899d3892bd9fa0, 79372cff3cc48a88, c2dae1ebe2ebd543, 13175843d3588af4, df06056fb16b3846, 758f06399b513c1f, a05d7278487d610b] tool: 1 --- # OAuth clients {#oauth-clients} @@ -81,18 +81,20 @@ authorization code flow को इंसान की ज़रूरत ठी जब `Client` पहली बार request भेजता है, server `401` लौटाता है। provider कमान संभाल लेता है: -1. **Discovery.** यह `WWW-Authenticate` header पढ़ता है, `/.well-known/oauth-protected-resource` से server का Protected Resource Metadata लाता है, पता करता है कि कौन सा authorization server इस resource की रक्षा करता है, और **उस** server का metadata लाता है। +1. **Discovery.** यह `WWW-Authenticate` header पढ़ता है, `/.well-known/oauth-protected-resource` से server का Protected Resource Metadata लाता है, पता करता है कि कौन सा authorization server इस resource की रक्षा करता है, और **उस** server का metadata लाता है। (कोई पुराना server जो resource metadata publish नहीं करता, उससे इसके बजाय उसके अपने origin पर authorization server metadata माँगा जाता है।) दोनों ही सूरतों में metadata को अपने `issuer` के रूप में उसी server का नाम देना होगा जिसके लिए उसे लाया गया था; इसके अलावा कुछ भी हो तो मना कर दिया जाता है। 2. **Registration.** storage में कुछ नहीं है? यह आपके `OAuthClientMetadata` के साथ आपको dynamically register करता है और नतीजा store कर लेता है। 3. **Authorization.** यह PKCE pair और `state` बनाता है, authorization URL तैयार करता है, आपके `redirect_handler` को await करता है, फिर code के लिए आपके `callback_handler` को await करता है। 4. **Exchange.** यह code के बदले `OAuthToken` लेता है, उसे store करता है, और आपकी मूल request को `Authorization: Bearer ...` के साथ दोबारा भेजता है। उसके बाद यह शांत रहता है। tokens storage से आते हैं, expire हुआ access token refresh token से refresh हो जाता है, और सिर्फ़ तब जब इनमें से कुछ काम नहीं करता, यह flow फिर से चलाता है। +इन सभी requests पर transport का एक नियम लागू होता है: जिस MCP request के अंदर ये चलती हैं उसी की तरह, ये किसी redirect को सिर्फ़ तभी follow करती हैं जब वह उसी origin पर रहे और method वही रखे (जैसे trailing-slash वाला 307/308), और किसी भी दूसरे redirect को ऐसे मानती हैं जैसे उस URL ने जवाब ही नहीं दिया। + आपने इसमें से कुछ नहीं लिखा। दो keyword arguments बचते हैं (`client_metadata_url` और `validate_resource_url`), और इस file को दोनों में से किसी की ज़रूरत नहीं। `client_metadata_url` जानने लायक है; इसका अपना section नीचे है। ### इसे आज़माएँ {#try-it} -इन docs के ज़्यादातर उदाहरण आप in-memory `Client(server)` से जाँच सकते हैं। यह नहीं: इस flow का पूरा मतलब ही HTTP `401` है, और in-memory client व उसके server के बीच कोई HTTP होता ही नहीं। +in-memory `Client(server)`, जिसे आपके tests इस्तेमाल करते हैं, यहाँ किसी काम का नहीं: इस flow का पूरा मतलब ही HTTP `401` है, और in-memory client व उसके server के बीच कोई HTTP होता ही नहीं। repository में live version मौजूद है। `examples/servers/simple-auth/` एक standalone authorization server और एक protected MCP server चलाता है; `examples/clients/simple-auth-client/` इसी page का client है, छोटी CLI में बढ़ा हुआ। उसकी README में दो commands हैं: servers शुरू करें, client को उनके सामने चलाएँ, और चारों चरण अपनी आँखों के सामने होते देखें। @@ -110,13 +112,14 @@ URL HTTPS होना चाहिए और उसका path root न हो; `ClientCredentialsOAuthProvider` वही `httpx2.Auth` है, बस इंसान के बिना: -```python title="client.py" hl_lines="4 27-33" +```python title="client.py" hl_lines="4 27-34" --8<-- "docs_src/oauth_clients/tutorial002.py" ``` क्या बदला: * न `OAuthClientMetadata`, न handlers। आप `client_id` और `client_secret` देते हैं; provider उनके इर्द-गिर्द एक न्यूनतम `client_credentials` registration बनाता है और dynamic registration पूरी तरह छोड़ देता है। +* `issuer` उस authorization server का नाम बताता है जिसने वे credentials जारी किए; वही `issuer` value इस्तेमाल करें जो उसका `/.well-known/oauth-authorization-server` document लौटाता है। discovery अब भी ऊपर बताए तरीके से चलती है, लेकिन token requests हमेशा सिर्फ़ **उसी** issuer के metadata से बनती हैं; अगर MCP server कहीं और इशारा करता है, तो flow इसके बजाय `OAuthFlowError` के साथ रुक जाता है। इसे छोड़ देना deprecated है और 3.0 में यह ज़रूरी हो जाएगा (**[Deprecated features](../deprecated.md#deprecated-sdk-helpers)** देखें); तब तक provider warning देता है और discovery को जो भी authorization server मिलता है, उसे इस्तेमाल करता है। * `scope` space से अलग की गई string है, OAuth का wire format। * आगे का सब कुछ बिल्कुल वही है: वही `TokenStorage`, वही `httpx2.AsyncClient(auth=...)`, वही `streamable_http_client`। @@ -129,7 +132,7 @@ default रूप से secret token request पर HTTP Basic auth के र एक और provider `mcp.client.auth.extensions.client_credentials` में रहता है: **`PrivateKeyJWTOAuthProvider`**, उन clients के लिए जो shared secret के बजाय JWT से authenticate करते हैं (`private_key_jwt`, key-pair और workload-identity वाला रूप)। यह उसी - pattern पर चलता है: एक बनाएँ, `auth=` पर लगाएँ। उसी module में + pattern पर चलता है: एक बनाएँ (यह भी वही optional `issuer` लेता है), `auth=` पर लगाएँ। उसी module में `SignedJWTParameters` और `static_assertion_provider` भी हैं, दो helpers जो इसका assertion बनाते हैं। बिना इंसान वाली एक और स्थिति है: client किसी enterprise का है जिसका identity provider, न कि user, तय करता है कि वह किन MCP servers तक पहुँच सकता है। वह अलग grant है, अपने trust model और अपने page के साथ, **[Identity assertion](identity-assertion.md)**। diff --git a/i18n/hi/pages/client/session-groups.md b/i18n/hi/pages/client/session-groups.md index f06c315b..f3e7eb5a 100644 --- a/i18n/hi/pages/client/session-groups.md +++ b/i18n/hi/pages/client/session-groups.md @@ -1,6 +1,6 @@ --- translation: - sections: [09c857a25a9dc37a, 43bc6a76a243a50e, 0a716022a88768df, 4b7f78042bfcfff7, c112662e61b03315, 58974ba1f489a8b4, d18adbdbb835ea73] + sections: [09c857a25a9dc37a, 43bc6a76a243a50e, 0a716022a88768df, 4b7f78042bfcfff7, c112662e61b03315, 58974ba1f489a8b4, ed4d17e894864056] tool: 1 --- # Session groups {#session-groups} @@ -78,7 +78,7 @@ translation: ## सारांश {#recap} * `ClientSessionGroup` कई server connections रखता है और उनके tools, resources और prompts को एक-एक `dict` में मिला देता है। -* हर server के लिए `connect_to_server(params)`। यह transport parameters लेता है, कभी वह server object या URL नहीं जो `Client` लेता है। +* हर server के लिए `connect_to_server(params)`। यह transport parameters लेता है, कभी वह URL या `Transport` नहीं जो `Client` लेता है। * `group.call_tool(name, arguments)` आपके लिए call को उस server तक पहुँचाता है जिसका वह tool है। * नाम पूरे group में unique होने ज़रूरी हैं; `search` tool वाले दो servers अपने आप साथ नहीं रह सकते। * `component_name_hook=` हर register किए गए नाम को फिर से लिखता है। dict key बदलती है, wire पर जाने वाला नाम नहीं। diff --git a/i18n/hi/pages/client/transports.md b/i18n/hi/pages/client/transports.md index e6cb756b..d049588d 100644 --- a/i18n/hi/pages/client/transports.md +++ b/i18n/hi/pages/client/transports.md @@ -1,6 +1,6 @@ --- translation: - sections: [9cac816674181eb0, 0700f337babcd4dd, 2bde0dd58cdf00f5, 40b4916d82eaf1d4, 3d0832f39b0d7059, dfa4446556badef0, 5bd93be2ab2ecb9c] + sections: [9cac816674181eb0, 7c157764133fea1f, 40b4916d82eaf1d4, 10d151f2cc75317f, 3d0832f39b0d7059, 92742ba36533633d, 0aeca6145e7bd302] tool: 1 --- # Client transports {#client-transports} @@ -11,30 +11,15 @@ translation: हर transport का **server** वाला पक्ष (`mcp.run()` क्या करता है और आप क्या deploy करते हैं) **[अपना server चलाना](../run/index.md)** में है। -## Memory में {#in-memory} - -server object ही पास करें: - -```python title="client.py" hl_lines="14" ---8<-- "docs_src/client_transports/tutorial001.py" -``` - -न कोई subprocess, न कोई port, न wire पर कोई bytes। client और server एक ही process में दो objects हैं, और call फिर भी असली protocol layer से होकर जाती है: `search_books` ठीक वैसे ही list, validate और invoke होता है जैसे HTTP पर होता। - -इससे यह एक साथ दो काम करता है: - -* **Test harness।** इस documentation का हर उदाहरण इसी तरीके से चलाया जाता है, और **[Testing](../get-started/testing.md)** page पूरा pattern इसी के इर्द-गिर्द बनाता है। -* **Embedding API।** जो application खुद server बनाता है, उसे उसके tools call करने के लिए network hop की ज़रूरत नहीं। - ## Streamable HTTP {#streamable-http} -URL string पास करें और आपको **Streamable HTTP** मिलता है, वह transport जिसके पीछे आप deploy करते हैं: +URL string पास करें और आपको **Streamable HTTP** मिलता है, वह transport जिसके पीछे आप deploy करते हैं और जिसे सबसे पहले चुनना चाहिए: ```python title="client.py" hl_lines="5" --8<-- "docs_src/client_transports/tutorial002.py" ``` -पूरा production client बस इतना ही है। `Client` आपके लिए URL को `streamable_http_client(...)` में लपेट देता है, एक `httpx2.AsyncClient` के ऊपर जो MCP की ज़रूरत के हिसाब से configure किया गया है: `follow_redirects=True`, connect/write/pool के लिए 30 सेकंड का timeout, और 300 सेकंड का read timeout, क्योंकि server response stream को खुला रख सकता है। +पूरा production client बस इतना ही है। `Client` आपके लिए URL को `streamable_http_client(...)` में लपेट देता है, एक `httpx2.AsyncClient` के ऊपर जो MCP की ज़रूरत के हिसाब से configure किया गया है: connect/write/pool के लिए 30 सेकंड का timeout, और 300 सेकंड का read timeout, क्योंकि server response stream को खुला रख सकता है। !!! check जो `Client` आपने बनाया है वह connected **नहीं** है। बनाने से सिर्फ़ transport चुना जाता है; @@ -50,7 +35,7 @@ URL string पास करें और आपको **Streamable HTTP** मि जैसे ही आपको `Authorization` header, cookie, proxy, mTLS या कोई अलग timeout चाहिए, `httpx2.AsyncClient` खुद बनाएँ और उसे `streamable_http_client` को दें: -```python title="client.py" hl_lines="8-14" +```python title="client.py" hl_lines="8-13" --8<-- "docs_src/client_transports/tutorial003.py" ``` @@ -80,9 +65,30 @@ environment variables set करें या अपने `httpx2.AsyncClient` !!! info `httpx2` जाना-पहचाना `httpx` API ही रखता है, इसलिए अगर आप `httpx` जानते हैं तो यहाँ auth, proxies, event hooks, retries और connection limits कैसे करने हैं, यह आप पहले से जानते हैं। SDK न ऊपर से कुछ जोड़ता है, न कुछ - हटाता है। OAuth भी यहीं जुड़ता है: + हटाता है, सिवाय [redirect handling](#redirects) के। OAuth भी यहीं जुड़ता है: `httpx2.AsyncClient(auth=OAuthClientProvider(...))`। वह पूरा flow **[OAuth clients](oauth-clients.md)** में है। +### Redirects {#redirects} + +transport उसी URL से जुड़ता है जो आपने दिया, और सिर्फ़ उसी origin से। + +* जो `307`/`308` redirect उसी scheme, host और port पर रहता है, उसे follow किया जाता है, और उसी host पर `http://` → `https://` को भी। आम `/mcp` → `/mcp/` वाला trailing-slash redirect इसी में आ जाता है। +* कहीं और जाने वाला redirect follow **नहीं** किया जाता। call इस error के साथ fail होती है: + + ```text + MCPError: Redirect to https://other.example.com/mcp not followed; use that URL as the endpoint if it is the intended server + ``` + + अगर वह URL वही server है जो आप चाहते थे, तो उसे अपने config में डालें। अगर नहीं, तो server या उसके आगे लगा कोई proxy गलत configure है। + +यह आपके पास किए गए किसी भी `httpx2.AsyncClient` पर लागू होता है: MCP requests के लिए उसकी `follow_redirects` setting नहीं देखी जाती, किसी भी दिशा में। SDK के OAuth providers अपनी requests पर यही नियम लागू करते हैं। + +!!! tip + `Redirect to http://… not followed: it would downgrade this HTTPS endpoint to plain HTTP` का मतलब है कि + server किसी ऐसे TLS-terminating proxy के पीछे है जिसके बारे में उसे पता नहीं, और वह `http://` redirects जारी कर रहा है। + इसे server पर ठीक किया जाता है (**[Deploy & scale](../run/deploy.md#behind-a-tls-terminating-proxy)**), + या ठीक वही `https://…/` URL इस्तेमाल करके जो message सुझाता है। + ## stdio {#stdio} **stdio** server एक subprocess है। client उसे launch करता है, उसके stdin पर JSON-RPC लिखता है और उसके stdout से JSON-RPC पढ़ता है। desktop host आपकी machine पर server इसी तरह चलाता है: host यही code **है**, बस ऊपर एक UI के साथ, और **[असली host से जुड़ें](../get-started/real-host.md)** यही रिश्ता host की तरफ़ से, एक config file के रूप में दिखाता है। @@ -105,6 +111,18 @@ child का stderr आपके stderr पर जाता है। उसे जिस server को API key चाहिए, उसे वह वहाँ नहीं मिलेगी। उसे `env=` से explicitly पास करें; वे variables allow-list के ऊपर merge हो जाते हैं। ऊपर `BOOKSHOP_API_KEY` यही कर रहा है। +## Memory में {#in-memory} + +test में न कुछ deploy करना है, न कुछ launch करना। server object ही पास करें: + +```python hl_lines="14" +--8<-- "docs_src/client_transports/tutorial001.py" +``` + +न कोई subprocess, न कोई port, न wire पर कोई bytes। client और server एक ही process में दो objects हैं, और call फिर भी असली protocol layer से होकर जाती है: `search_books` ठीक वैसे ही list, validate और invoke होता है जैसे HTTP पर होता। **[Testing](../get-started/testing.md)** पूरा pattern इसी के इर्द-गिर्द बनाता है। + +यही रूप embedding API का काम भी करता है: जो application खुद server बनाता है, वह बिना network hop के उसके tools call कर सकता है। + ## SSE {#sse} `mcp.client.sse` का `sse_client(url)` वह HTTP transport है जिसकी जगह Streamable HTTP ने ली। जो server अब भी इसे बोलता है, उससे बात करने के लिए इसे उसी तरह wrap करें, `Client(sse_client("http://localhost:8000/sse"))`, और इस पर कुछ नया न बनाएँ। @@ -113,15 +131,16 @@ child का stderr आपके stderr पर जाता है। उसे `Client` के लिए ऊपर की सभी चीज़ें एक ही हैं। -**transport** कोई भी async context manager है जो message streams का `(read, write)` जोड़ा yield करता है: औपचारिक रूप से, `mcp.client` का `Transport` protocol। `Client` अपने argument को type से resolve करता है: server object in-process जुड़ता है, `str` `streamable_http_client(url)` बन जाता है, `StdioServerParameters` `stdio_client(params)` बन जाता है, और बाकी सब कुछ सीधे transport के रूप में enter किया जाता है। यही आख़िरी नियम वजह है कि `stdio_client(...)`, `streamable_http_client(...)` और `sse_client(...)` सब उसी एक slot में बैठते हैं, और यही वजह है कि आप अपना खुद का भी लिख सकते हैं। +**transport** कोई भी async context manager है जो message streams का `(read, write)` जोड़ा yield करता है: औपचारिक रूप से, `mcp.client` का `Transport` protocol। `Client` अपने argument को type से resolve करता है: `str` `streamable_http_client(url)` बन जाता है, `StdioServerParameters` `stdio_client(params)` बन जाता है, server object in-process जुड़ता है, और बाकी सब कुछ सीधे transport के रूप में enter किया जाता है। यही आख़िरी नियम वजह है कि `stdio_client(...)`, `streamable_http_client(...)` और `sse_client(...)` सब उसी एक slot में बैठते हैं, और यही वजह है कि आप अपना खुद का भी लिख सकते हैं। ## सारांश {#recap} -* `Client(mcp)` (server object) memory में जुड़ता है। इसे tests और embedding के लिए इस्तेमाल करें। * `Client("http://.../mcp")` (URL) Streamable HTTP पर जुड़ता है, जो production transport है। * Headers, auth, proxies और timeouts उस `httpx2.AsyncClient` पर होने चाहिए जो आप `streamable_http_client(url, http_client=...)` को पास करते हैं। कोई `headers=` keyword नहीं है। +* Redirects सिर्फ़ URL के अपने origin के भीतर follow होते हैं (trailing-slash वाला `307`/`308`), और उसी host पर `http`→`https`। बाकी सब `Redirect to … not followed` के साथ fail होता है; final URL configure करें। * stdio है `Client(StdioServerParameters(...))`। इसे खुद `stdio_client(...)` में सिर्फ़ तब wrap करें जब child का stderr कहीं और भेजना हो। * subprocess को allow-list वाला environment मिलता है, आपका नहीं; `env=` उसमें जोड़ता है। +* `Client(mcp)` (server object) memory में जुड़ता है। इसे tests में इस्तेमाल करें, या server को उसी application में embed करने के लिए जिसने उसे बनाया। * transport वह हर चीज़ है जिस पर आप `async with x as (read, write)` कर सकें। जो कुछ server object, URL या `StdioServerParameters` नहीं है, `Client` उसे सीधे उसी protocol को सौंप देता है। * `Client` बनाने से transport चुना जाता है। `async with` उसे खोलता है। diff --git a/i18n/hi/pages/deprecated.md b/i18n/hi/pages/deprecated.md index d986c75a..db2b439e 100644 --- a/i18n/hi/pages/deprecated.md +++ b/i18n/hi/pages/deprecated.md @@ -1,11 +1,11 @@ --- translation: - sections: [490237e61c3a7a44, 01262a123ad9501d, 429db5b574a2ac08, e2d0d273fbd2d74b, 64ab0331e868f3d4, 6c8878ce2d1f6d56, 4068f23e371bf0b3, eaef75b8725bc931] + sections: [4c1dea378b2b1bf7, 01262a123ad9501d, 429db5b574a2ac08, e2d0d273fbd2d74b, 64ab0331e868f3d4, 6c8878ce2d1f6d56, c3d1099701156881, e185a1b8e53669f6] tool: 1 --- # Deprecated features {#deprecated-features} -2026-07-28 spec पाँच चीज़ों को retire करता है। SDK अब भी इनमें से हर एक को implement करता है, और अब हर एक पर **deprecation warning** लगी है। एक SDK helper अपनी अलग वजह से deprecated है और [आख़िर में](#deprecated-sdk-helpers) दिया गया है। +2026-07-28 spec पाँच चीज़ों को retire करता है। SDK अब भी इनमें से हर एक को implement करता है, और अब हर एक पर **deprecation warning** लगी है। कुछ SDK-level deprecations अपनी अलग वजह से हैं और [आख़िर में](#deprecated-sdk-helpers) दी गई हैं। नीचे दी गई table हर deprecated feature का नाम, उसके हटने की वजह, और उसकी जगह किस replacement पर build करना है, यह बताती है। @@ -137,11 +137,13 @@ warnings.filterwarnings("ignore", category=MCPDeprecationWarning) ## Deprecated SDK helpers {#deprecated-sdk-helpers} -ये spec के बदलाव नहीं हैं, सिर्फ़ SDK के अंदरूनी हिस्से हैं जिनका बेहतर replacement मौजूद है। ये उसी `MCPDeprecationWarning` के साथ warn करते हैं और 3.0 में हटा दिए जाएँगे। +ये spec के बदलाव नहीं हैं, सिर्फ़ SDK के इस्तेमाल के वे तरीके हैं जिनका बेहतर replacement मौजूद है। ये उसी `MCPDeprecationWarning` के साथ warn करते हैं, और 3.0 पुराना रूप हटा देता है। | Deprecated | इसके बजाय क्या करें | |---|---| | `FuncMetadata.call_fn_with_arg_validation()` | `FuncMetadata.validate_arguments()` और फिर `FuncMetadata.call_fn()`। इसे सिर्फ़ वही code call करता था जो `FuncMetadata` को सीधे चलाता है (जैसे कोई custom `Tool` subclass)। | +| `validate_token_resource=` के बिना `AuthSettings(resource_server_url=...)` | इसे set करें: `True` पर server वे bearer tokens ठुकरा देता है जिन्हें आपका verifier `resource_server_url` के लिए जारी हुआ नहीं बताता, `False` का मतलब है कि आपका verifier token का audience ख़ुद जाँचता है (**[Authorization](run/authorization.md#a-token-verifier)** देखें)। set न होने पर व्यवहार `False` जैसा है; 3.0 में जब भी `resource_server_url` set हो, default `True` होगा। | +| `issuer=` के बिना `ClientCredentialsOAuthProvider(...)` या `PrivateKeyJWTOAuthProvider(...)` | `issuer=` दें, जिसमें credentials जारी करने वाले authorization server का नाम हो (**[OAuth clients लिखना](client/oauth-clients.md#machine-to-machine)** देखें)। इसके बिना MCP server तय करता है कि credentials किस authorization server को मिलें; 3.0 में यह keyword ज़रूरी हो जाएगा। | ## सारांश {#recap} @@ -150,7 +152,7 @@ warnings.filterwarnings("ignore", category=MCPDeprecationWarning) * Deprecated होना बस सलाह भर है: wire में कोई बदलाव नहीं, 2026 से पहले के sessions पर सब कुछ काम करता रहता है, और आपको साफ़ दिखने वाली `MCPDeprecationWarning` मिलती है (यह `UserWarning` है, इसलिए default रूप से चालू है)। * sampling और roots को इसके अलावा back-channel चाहिए जो 2026-07-28 session के पास नहीं है। modern connection पर ये warn करते हैं और फिर raise करते हैं। * `warnings.filterwarnings("ignore", category=MCPDeprecationWarning)` पूरी category को चुप कराता है; pytest में `"error::mcp.MCPDeprecationWarning"` इसे test failure में बदल देता है। -* एक SDK helper, `FuncMetadata.call_fn_with_arg_validation()`, अलग से deprecated है और 3.0 में हटाया जाएगा। +* [SDK-level deprecations](#deprecated-sdk-helpers) पर भी यही नियम लागू है: अभी ये warn करते हैं, और 3.0 पुराना रूप हटा देता है। * नया code इनमें से किसी पर भी नहीं बनना चाहिए। इन docs का बाकी हर page मौजूदा API सिखाता है। diff --git a/i18n/hi/pages/get-started/first-steps.md b/i18n/hi/pages/get-started/first-steps.md index 89dffcdc..3d00d05f 100644 --- a/i18n/hi/pages/get-started/first-steps.md +++ b/i18n/hi/pages/get-started/first-steps.md @@ -1,6 +1,6 @@ --- translation: - sections: [0d6c05bcbf836bf3, 59a7b14eeefc68c1, 7114d8d6daba203f, e8bbb56a98ba7bc9, 5138010f6159901c, f78da7c7c363d4c6, 220a939cab348686] + sections: [0d6c05bcbf836bf3, 9a78b5f6b44b18ab, 7114d8d6daba203f, e8bbb56a98ba7bc9, bfd2fd1153e71dac, 1615a994ef071fdd, 65c599fae991f245] tool: 1 --- # पहले कदम {#first-steps} @@ -17,7 +17,7 @@ translation: * **client** host के अंदर रहता है और MCP बोलता है। host जितने servers से जुड़ा है, हर एक के लिए एक client चलाता है। * **server** वह है जो आप इस SDK से बनाते हैं। यह clients को चीज़ें expose करता है। यह model से सीधे कभी बात नहीं करता। -server आप लिखते हैं। hosts किसी और का product हैं। SDK आपको एक `Client` भी देता है। इससे आप अपने servers test करेंगे, और यह इसी page पर आगे दिखता है। +server आप लिखते हैं। hosts किसी और का product हैं। SDK आपको `Client` भी देता है, वही class जिससे कोई host URL से server तक पहुँचता है या उसे subprocess के रूप में launch करता है। यह इसी page पर आगे दिखता है, और इसी से आप अपने servers test भी करेंगे। ## तीन primitives {#the-three-primitives} @@ -83,22 +83,20 @@ Inspector में आपने तीन tabs देखे। उसे कै जब client जुड़ता है, server अपनी **capabilities** declare करता है: requests के कौन-से परिवारों का वह जवाब देगा। client उसी declaration से तय करता है कि माँगे भी तो क्या। आपने यह कभी नहीं लिखा; `MCPServer` आपके लिए इसे declare करता है। -खुद देखें। SDK का `Client` सीधे server object लेता है और उससे **in memory** जुड़ता है (न subprocess, न port): +खुद देखें। एक terminal में `server.py` को HTTP पर चलता छोड़ दें: -```python -import asyncio +```console +uv run mcp run server.py --transport streamable-http +``` -from mcp import Client +और दूसरे terminal से client को उससे जोड़ें: -from server import mcp +```python title="client.py" hl_lines="7-8" +--8<-- "docs_src/first_steps/tutorial001_client.py" +``` - -async def main() -> None: - async with Client(mcp) as client: - print(client.server_capabilities.model_dump(exclude_none=True)) - - -asyncio.run(main()) +```console +python client.py ``` ```text @@ -118,8 +116,9 @@ asyncio.run(main()) ध्यान दें कि क्या नहीं है। `completions` (resource templates और prompts के लिए argument autocomplete) को आपका लिखा handler चाहिए, इस server में वह नहीं है, इसलिए capability मौजूद नहीं है और सलीके वाला client पूछेगा ही नहीं। हर optional चीज़ का यही नियम है: चीज़ register करें और capability आ जाती है; **[Completions](../servers/completions.md)** इसे साबित करता है। !!! info - `Client(mcp)` वही in-memory client है जिससे इन docs का हर उदाहरण test होता है, और - इसी से आप अपने servers test करेंगे। इसे पूरा एक page मिलता है: **[Testing](testing.md)**। + वह `client.py` पूरा MCP client है, और **[Client](../client/index.md)** उसका page है। + test में आप terminal और port छोड़ देते हैं और `Client` को सीधे server object ही थमा देते हैं, + `Client(mcp)`। उसे भी पूरा एक page मिलता है: **[Testing](testing.md)**। ## जो आपने नहीं लिखा {#what-you-did-not-write} @@ -128,7 +127,7 @@ asyncio.run(main()) * JSON Schema। `a: int, b: int` **ही** `add` का schema है। * request handler। `tools/list`, `resources/read`, `prompts/get`: सब आपके लिए serve होते हैं। * capability declaration। `MCPServer` ने आपके लिए बना दिया। -* protocol की एक भी line। version negotiation, JSON-RPC framing, capability exchange: यह सब `mcp dev` और `Client(mcp)` के अंदर हुआ, और आपने कभी देखा ही नहीं। +* protocol की एक भी line। version negotiation, JSON-RPC framing, capability exchange: यह सब `mcp dev` और `client.py` के अंदर हुआ, और आपने कभी देखा ही नहीं। यही अनुपात SDK का पूरा मतलब है। @@ -139,6 +138,6 @@ asyncio.run(main()) * हर primitive के लिए एक decorator: `@mcp.tool()`, `@mcp.resource(uri)`, `@mcp.prompt()`। नाम, description और schema function से आते हैं। * `{param}` वाला URI resource **template** बनाता है, जो concrete resources से अलग सूची में दिखता है। * server की **capabilities** आपके लिए declare हो जाती हैं, और client वही माँगता है जो server declare करता है। -* `Client(mcp)` server object से in memory जुड़ता है: पहले दिन से आपका test harness। +* `Client("http://localhost:8000/mcp")` आपके चलते हुए server से बात करता है। उसकी जगह इसे server object थमा दें, `Client(mcp)`, और पहले दिन से यही आपका test harness है। आगे है **[असली host से जुड़ें](real-host.md)**: यही server Claude Desktop या किसी IDE के अंदर, सच में। फिर **[Testing](testing.md)**: एक page, एक in-memory client, और आपको कभी अंदाज़ा नहीं लगाना पड़ेगा कि यह काम करता है या नहीं। उसके बाद हर primitive को अपना page मिलता है, शुरुआत उससे जिसे model चलाता है: **[Tools](../servers/tools.md)**। diff --git a/i18n/hi/pages/get-started/testing.md b/i18n/hi/pages/get-started/testing.md index 2e72aa19..c7cdf9b8 100644 --- a/i18n/hi/pages/get-started/testing.md +++ b/i18n/hi/pages/get-started/testing.md @@ -1,13 +1,13 @@ --- translation: - sections: ['4926721070127497', c52a1de2b6b32f40, 8e792bf8c7489ec6, 627195f7159e24ef] + sections: [5d13c2f0ba42c0d2, c52a1de2b6b32f40, 8e792bf8c7489ec6, 38552ea228b0a04f] tool: 1 --- # Testing {#testing} -Python SDK में `Client` class आती है जिसके साथ **in-memory transport** मिलता है: इसे अपना server object दें और यह उससे सीधे जुड़ जाता है। +SDK की `Client` class, वही जो किसी URL से जुड़ती है या subprocess शुरू करती है, **in memory** भी जुड़ती है: इसे अपना server object दें और यह उससे सीधे बात करती है। -कोई subprocess नहीं। कोई port नहीं। कोई transport ही नहीं। यह वही विचार है जो FastAPI के `TestClient` का है। +कोई subprocess नहीं। कोई port नहीं। wire पर कुछ नहीं। यह वही विचार है जो FastAPI के `TestClient` का है। ## Basic usage {#basic-usage} @@ -91,7 +91,7 @@ tool body के **बाहर** का failure अलग है। `Client(mcp tests में इसे चालू रहने दें। production code में इसका कोई मतलब नहीं है। -## Default रूप से in-process {#in-process-by-default} +## Default रूप से पीढ़ी-निरपेक्ष {#era-neutral-by-default} !!! note `Client(mcp)` in-process जुड़ता है और default रूप से **पीढ़ी-निरपेक्ष** है: यह server को probe करता है और diff --git a/i18n/hi/pages/handlers/multi-round-trip.md b/i18n/hi/pages/handlers/multi-round-trip.md index 251145ad..4928269c 100644 --- a/i18n/hi/pages/handlers/multi-round-trip.md +++ b/i18n/hi/pages/handlers/multi-round-trip.md @@ -1,6 +1,6 @@ --- translation: - sections: [74011e683045eea9, 9b64cc175c18b6a9, 4b41be4824030397, e3b1502da786ec33, 71e41161f143c6a9, 9ec2c1eeb8c36378, 8dd027377d46448b, f81491125dcbfe8b] + sections: [74011e683045eea9, 9b64cc175c18b6a9, 4b41be4824030397, e3b1502da786ec33, 71e41161f143c6a9, 9ec2c1eeb8c36378, b47667184ca5b516, f81491125dcbfe8b] tool: 1 --- # Multi-round-trip requests {#multi-round-trip-requests} @@ -164,7 +164,7 @@ low-level `Server` बिना-batteries वाला स्तर है: `MCP ## एक 2026-07-28 result {#a-2026-07-28-result} -`InputRequiredResult` सिर्फ़ protocol version **2026-07-28** पर मौजूद है। in-memory `Client(server)` इसे आपके लिए negotiate करता है; wire पर, `mode="auto"` इसे खोज लेता है। connect करने के बाद `client.protocol_version` बताता है कि आपको क्या मिला। +`InputRequiredResult` सिर्फ़ protocol version **2026-07-28** पर मौजूद है। `Client` का default `mode="auto"` इसे किसी भी connection पर खोज लेता है। connect करने के बाद `client.protocol_version` बताता है कि आपको क्या मिला। !!! warning pre-2026 session के पास `InputRequiredResult` रखने की कोई जगह नहीं है। `mode="legacy"` connection पर diff --git a/i18n/hi/pages/handlers/progress.md b/i18n/hi/pages/handlers/progress.md index b7b3f16c..0ccf140b 100644 --- a/i18n/hi/pages/handlers/progress.md +++ b/i18n/hi/pages/handlers/progress.md @@ -1,6 +1,6 @@ --- translation: - sections: [5315262fe26b33e1, 9d8e98840f1b78f0, 0284b215e85366c4, 8534d8dbb4053a70, 2966fac6fe697007] + sections: [5315262fe26b33e1, 9d8e98840f1b78f0, 52d6009a07e770ea, 8534d8dbb4053a70, 2966fac6fe697007] tool: 1 --- # Progress {#progress} @@ -27,21 +27,19 @@ translation: ## Client से सुनें {#listen-for-it-from-the-client} -Client **हर call पर** अलग से opt in करता है, `call_tool` को `progress_callback=` देकर: +client **हर call पर** अलग से opt in करता है, `call_tool` को `progress_callback=` देकर: -```python title="client.py" hl_lines="7 16" +```python title="client.py" hl_lines="5 14" import anyio from mcp import Client -from server import mcp - async def show(progress: float, total: float | None, message: str | None) -> None: print(f"{message} ({progress}/{total})") async def main() -> None: - async with Client(mcp) as client: + async with Client("http://localhost:8000/mcp") as client: result = await client.call_tool( "import_catalog", {"urls": ["https://example.com/a.json", "https://example.com/b.json"]}, @@ -53,30 +51,34 @@ async def main() -> None: anyio.run(main) ``` -Callback एक `async` function है जो ठीक वही लेता है जो server ने report किया: `progress`, `total`, `message`। +callback एक `async` function है जो ठीक वही लेता है जो server ने report किया: `progress`, `total`, `message`। !!! info - `Client(mcp)` सीधे server object से जुड़ता है, memory में, वही client जिस पर **[Testing](../get-started/testing.md)** - page बना है। `Client` चाहे कोई भी transport इस्तेमाल करे, `progress_callback` parameter वही रहता है; - जो **timing** आप अभी देखने वाले हैं वह in-memory connection की है। वह आपका callback inline चलाता है, - इसलिए हर report `call_tool` के लौटने से पहले पहुँच जाती है। असली transport पर notifications और result - में होड़ लगती है, और एक धीमा callback `call_tool` के लौटने के बाद भी चल रहा हो सकता है। + `Client` को आपने चाहे जो भी दिया हो, `progress_callback` parameter वही रहता है: यहाँ की तरह + URL, कोई `StdioServerParameters`, या test में server object। हाँ, असली transport पर timing का + ध्यान रखें। हर notification अपने आप में अलग, response के साथ-साथ पहुँचता है, इसलिए एक धीमा + callback `call_tool` के लौटने के बाद भी चल रहा हो सकता है। सिर्फ़ in-process test connection + ही callback को inline चलाता है और यह पक्का करता है कि हर report पहले पहुँचे। ### इसे आज़माएँ {#try-it} -`client.py` को `server.py` के बगल में रखें और चलाएँ: +`server.py` को HTTP पर serve करें, फिर दूसरे terminal से client चलाएँ: + +```console +uv run mcp run server.py --transport streamable-http +``` ```console python client.py ``` ```text -Imported https://example.com/a.json (1/2) -Imported https://example.com/b.json (2/2) +Imported https://example.com/a.json (1.0/2.0) +Imported https://example.com/b.json (2.0/2.0) {'result': 'Imported 2 records.'} ``` -Server पर हर `await ctx.report_progress(...)` client पर `show` का एक call बना, उसी क्रम में, और दोनों lines `call_tool` के लौटने से **पहले** print हुईं। Progress result में बंडल होकर नहीं आता; tool के काम करते रहने के दौरान ही stream होता है। +server पर हर `await ctx.report_progress(...)` client पर `show` का एक call बना, उसी क्रम में। progress result में समेटकर नहीं भेजा जाता। वह tool के काम करते रहने के दौरान ही stream होता है। !!! warning `progress_callback` **call** का है, `Client` का नहीं। इसके लिए कोई constructor argument नहीं है, diff --git a/i18n/hi/pages/protocol-versions.md b/i18n/hi/pages/protocol-versions.md index 79eecb1c..8280d042 100644 --- a/i18n/hi/pages/protocol-versions.md +++ b/i18n/hi/pages/protocol-versions.md @@ -1,6 +1,6 @@ --- translation: - sections: [478fd619e5f90ef8, aef094a00e44e248, bab8cbf3449fa7e9, df1809b15a58335b, 5f9d8c2336ed0239, f54974398e43ddef, b24443dd78584870] + sections: [d98e337bf28845e0, dd642acbba36f615, f547b3e76da19c94, 149513378588da5c, 4e149ed992046709, f54974398e43ddef, b24443dd78584870] tool: 1 --- # Protocol versions {#protocol-versions} @@ -11,9 +11,17 @@ MCP की दो पीढ़ियाँ हैं। आपको इसकी चिंता लगभग कभी नहीं करनी पड़ती, क्योंकि `Client` आपके लिए negotiate कर लेता है। यह page उस एक constructor argument के बारे में है जो इसे नियंत्रित करता है, `mode=`, और उन तीन मौकों के बारे में जब आप इसे बदलते हैं। +इस page का हर snippet एक `client.py` है जो **[Client](client/index.md)** वाले Bookshop `server.py` से बात करता है। उस server को एक terminal में शुरू करें: + +```console +uv run mcp run server.py --transport streamable-http +``` + +फिर हर snippet को दूसरे terminal में `python client.py` से चलाएँ। + ## `mode="auto"` {#modeauto} -```python title="client.py" hl_lines="14-15" +```python title="client.py" hl_lines="7-8" --8<-- "docs_src/protocol_versions/tutorial001.py" ``` @@ -31,13 +39,14 @@ MCP की दो पीढ़ियाँ हैं। पूरा feature बस इतना ही है। एक `Client`, किसी भी पीढ़ी का server, और code में कोई branching नहीं। !!! info - `MCPServer` हर transport पर `server/discover` का जवाब देता है — in-memory, stdio, streamable - HTTP — इसलिए आपके अपने server के साथ `auto` हमेशा `2026-07-28` पर पहुँचता है। fallback सिर्फ़ - असली pre-2026 server के सामने ही चलता है, और ठीक वहीं आप इसे चाहते भी हैं। + `MCPServer` हर transport पर `server/discover` का जवाब देता है — Streamable HTTP, stdio, और वह + in-process connection जो आपके tests इस्तेमाल करते हैं — इसलिए आपके अपने server के साथ `auto` हमेशा + `2026-07-28` पर पहुँचता है। fallback सिर्फ़ असली pre-2026 server के सामने ही चलता है, और ठीक वहीं + आप इसे चाहते भी हैं। ## `mode="legacy"` {#modelegacy} -```python title="client.py" hl_lines="14" +```python title="client.py" hl_lines="7" --8<-- "docs_src/protocol_versions/tutorial002.py" ``` @@ -57,11 +66,11 @@ server-initiated request का मतलब है server का **आपको `mode="auto"` handshake तभी देता है जब server इतना पुराना हो कि और कुछ चले ही नहीं। `mode="legacy"` इसकी गारंटी देता है। जब भी आप `Client(...)` को `sampling_callback`, request के रूप में चलाया जाने वाला `elicitation_callback`, या `message_handler` देते हैं, इसे चुनें। **[Client callbacks](client/callbacks.md)** में हर एक की बात विस्तार से है। -## Version pin करना {#pinning-a-version} +## version pin करना {#pinning-a-version} `mode` modern protocol version string भी स्वीकार करता है। आज यह set ठीक `["2026-07-28"]` है। -```python title="client.py" hl_lines="14" +```python title="client.py" hl_lines="7" --8<-- "docs_src/protocol_versions/tutorial003.py" ``` @@ -94,7 +103,7 @@ probe सस्ता है, लेकिन फिर भी यह एक rou इसलिए इसे संभाल कर रखें। `auto` connection के बाद `client.session.discover_result` में ठीक वही `DiscoverResult` होता है जो server ने भेजा था: उसके `supported_versions`, उसकी `capabilities`, उसके `instructions`, और वह पहचान जो server ने result के `_meta` में दर्ज की थी। अगली बार इसे `prior_discover=` के रूप में वापस दें: -```python title="client.py" hl_lines="15 17" +```python title="client.py" hl_lines="8 10" --8<-- "docs_src/protocol_versions/tutorial004.py" ``` diff --git a/i18n/hi/pages/run/asgi.md b/i18n/hi/pages/run/asgi.md index aab87362..c336e7eb 100644 --- a/i18n/hi/pages/run/asgi.md +++ b/i18n/hi/pages/run/asgi.md @@ -1,6 +1,6 @@ --- translation: - sections: [1062ef792791488a, 4be2b831547184a9, 374b049e770385f2, b72f6947089e6de0, b172c9db7831bb31, 70b9ece244ca1b0c, cba78e052898c3f6, f06bdb541cb0b469, fb82d526320b7cc3] + sections: [1062ef792791488a, 4be2b831547184a9, 374b049e770385f2, b72f6947089e6de0, b172c9db7831bb31, 10394c6f16601638, cba78e052898c3f6, f06bdb541cb0b469, 6dc898ccc5a903f9] tool: 1 --- # मौजूदा app में जोड़ना {#add-to-an-existing-app} @@ -99,7 +99,7 @@ Starlette का `Host` route इसी तरह काम करता है: --8<-- "docs_src/asgi/tutorial004.py" ``` -अब clients `/notes` से जुड़ते हैं, `/notes/mcp` से नहीं। +अब clients `/notes/` से जुड़ते हैं, `/notes/mcp` से नहीं। ## browser clients के लिए CORS {#cors-for-browser-clients} @@ -142,4 +142,4 @@ browser-based client को आपसे दो अनुमतियाँ च * browser clients को CORS चाहिए: `Mcp-*` request headers के लिए `allow_headers`, response के लिए `expose_headers=["Mcp-Session-Id"]`। * `@mcp.custom_route()` `/mcp` के बगल में सादे, बिना authentication वाले HTTP endpoints जोड़ता है। -जब server असली URL पर पहुँच में आ जाए, तो **[Client](../client/index.md)** server object के बजाय उसी URL से उससे जुड़ता है। +जब server असली URL पर पहुँच में आ जाए, तो **[Client](../client/index.md)** उसी URL से उससे जुड़ता है। diff --git a/i18n/hi/pages/run/authorization.md b/i18n/hi/pages/run/authorization.md index 40f93237..e284739d 100644 --- a/i18n/hi/pages/run/authorization.md +++ b/i18n/hi/pages/run/authorization.md @@ -1,6 +1,6 @@ --- translation: - sections: [d62c13457fc4a534, 80e73abaca6e0652, d1dc4c54cd00ec9c, 14ad3bc7904036bb, 5225f127bc1b9c77, fe1626fdd5aad1da, 4556cb7ea1a04a31] + sections: [d62c13457fc4a534, 80e73abaca6e0652, 128a492d18295f64, 14ad3bc7904036bb, 54a6697833fcc1d9, fe1626fdd5aad1da, 811d083c1da8bcf6] tool: 1 --- # Authorization {#authorization} @@ -23,12 +23,12 @@ OAuth की भाषा में, आपका server **resource server** ह valid token कैसा दिखता है, इस बारे में SDK की कोई राय नहीं है। यह आप बताते हैं, **`TokenVerifier`** implement करके: -```python title="server.py" hl_lines="12-14 19-24" +```python title="server.py" hl_lines="14-16 21-27" --8<-- "docs_src/authorization/tutorial001.py" ``` * `TokenVerifier` एक async method वाला protocol है। `verify_token` को `Authorization` header से raw token मिलता है, और token valid हो तो यह **`AccessToken`** लौटाता है, न हो तो `None`। इसके अलावा implement करने को कुछ नहीं है। -* यह वाला token को एक table में ढूँढता है। असली verifier JWT signature verify करता है या authorization server के token-introspection endpoint को call करता है। वह code आपका है; SDK उसे सिर्फ़ call करता है। +* यह वाला token को एक table में ढूँढता है; हर entry में दर्ज है कि token किस resource के लिए जारी हुआ था। असली verifier JWT signature verify करता है या authorization server के token-introspection endpoint को call करता है, और `AccessToken.resource` में बताता है कि token किसके लिए जारी हुआ था (उसका `aud`)। वह code आपका है; SDK उसे सिर्फ़ call करता है। * `token_verifier=` और `auth=` हमेशा साथ चलते हैं। एक को दूसरे के बिना pass करें तो `MCPServer(...)` कोई request serve करने से पहले ही `ValueError` raise कर देता है। `AuthSettings` आपके resource server का सार्वजनिक चेहरा है: @@ -36,6 +36,10 @@ valid token कैसा दिखता है, इस बारे में S * `issuer_url`: वह authorization server जो आपके tokens जारी करता है। * `resource_server_url`: इस MCP endpoint का public URL। यह बताता है कि token **किस** resource के लिए है, और discovery document भी यहीं रहता है। * `required_scopes`: हर token में ये सभी होने ही चाहिए। +* `validate_token_resource`: हर उस token को मना कर देता है जिसका `AccessToken.resource` `resource_server_url` नहीं है। `resource_server_url` set हो और इसे unset छोड़ दें तो warning (`MCPDeprecationWarning`) आती है और यह `False` की तरह बर्ताव करता है; 3.0 में resource servers के लिए default `True` हो जाएगा। + * इसे तब चालू करें जब आपका authorization server tokens को उस `resource` से बाँधता हो जो client ने माँगा था, और जिसे MCP clients हमेशा भेजते हैं। `resource_server_url` ठीक वही URL रखें जिससे clients जुड़ते हैं। + * इसे तब बंद रहने दें जब आपका authorization server अपने खुद के audience identifiers इस्तेमाल करता हो (Auth0 API identifier, Entra application ID), और इसकी जगह अपने verifier में `aud` जाँचें, और जो token इस server के लिए नहीं है उसके लिए `None` लौटाएँ। + * अगर `aud` एक list है, तो जो entry `resource_server_url` के बराबर है उसे `resource` में रखें। !!! tip SDK repository में `examples/servers/simple-auth/` के अंदर एक `IntrospectionTokenVerifier` है जो @@ -91,7 +95,7 @@ authorization HTTP headers में रहता है, इसलिए यह किसी भी handler के अंदर, **`get_access_token()`** वही `AccessToken` है जो आपके verifier ने मौजूदा request के लिए लौटाया था: -```python title="server.py" hl_lines="4 32-35" +```python title="server.py" hl_lines="4 35-38" --8<-- "docs_src/authorization/tutorial002.py" ``` @@ -125,6 +129,6 @@ authorization server, user के consent screen पर click करने क * `token_verifier=` और `auth=AuthSettings(issuer_url=..., resource_server_url=..., required_scopes=[...])` हमेशा साथ चलते हैं। * SDK `/.well-known/oauth-protected-resource/...` पर [RFC 9728](https://datatracker.ietf.org/doc/html/rfc9728) Protected Resource Metadata publish करता है और unauthenticated requests का जवाब 401 से देता है, जिसका `WWW-Authenticate` header उसी की ओर इशारा करता है। discovery की पूरी कहानी बस इतनी ही है। * किसी भी handler में `get_access_token()` बताता है कि call कौन कर रहा है। -* authorization HTTP का मामला है। `stdio` और in-memory client इसे कभी नहीं देखते। +* authorization HTTP का मामला है। `stdio` और in-memory test client इसे कभी नहीं देखते। client वाला आधा हिस्सा (आपके authorization server को खोजना और आपके लिए token लाना) **[OAuth clients](../client/oauth-clients.md)** में है। और जो client user से पहचान पूछने के बजाय खुद कोई पहचान **assert** करता है, वह **[Identity assertion](../client/identity-assertion.md)** में है। diff --git a/i18n/hi/pages/run/deploy.md b/i18n/hi/pages/run/deploy.md index b27a3a93..9afb3992 100644 --- a/i18n/hi/pages/run/deploy.md +++ b/i18n/hi/pages/run/deploy.md @@ -1,6 +1,6 @@ --- translation: - sections: [28221886b198784f, f88ea1f1614f3a1d, ce926d686730b6d0, 3be24f8ad8bb5ab9, 3fad24032b2224ff, f25a7f860e579ecb, e758745df6fb7b0a] + sections: [28221886b198784f, f88ea1f1614f3a1d, 2e76f5cb9df15042, ce926d686730b6d0, 3be24f8ad8bb5ab9, 3fad24032b2224ff, f25a7f860e579ecb, 697b01d95080880d] tool: 1 --- # Deploy और scale करना {#deploy-scale} @@ -47,6 +47,22 @@ translation: deploy हुआ server जो हर connection ठुकरा रहा हो, उसे Host allowlist की समस्या ही मानें जब तक कुछ और साबित न हो। **[Troubleshooting](../troubleshooting.md)** भी यहीं से शुरू होता है। +## TLS-terminating proxy के पीछे {#behind-a-tls-terminating-proxy} + +अगर TLS किसी proxy पर ख़त्म होता है (कोई ingress, load balancer, Caddy, nginx) और uvicorn उसके पीछे plain HTTP serve करता है, तो uvicorn को बताएँ कि वह proxy के `X-Forwarded-*` headers पर भरोसा करे: + +```console +uvicorn server:app --proxy-headers --forwarded-allow-ips='' +``` + +इसके बिना app यह मान लेता है कि उसे `http://` पर serve किया जा रहा है, और वह जो भी redirect भेजता है (आम तौर पर `/mcp` → `/mcp/`) वह `http://…` की ओर इशारा करता है। Python client किसी HTTPS endpoint से plain HTTP पर जाने से इनकार कर देता है और साफ़ बताता है: + +```text +MCPError: Redirect to http://mcp.example.com/mcp/ not followed: it would downgrade this HTTPS endpoint to plain HTTP. +``` + +client की तरफ़ का कामचलाऊ उपाय है ठीक वही URL configure करना जो server serve करता है (`https://mcp.example.com/mcp/`, slash सहित), ताकि कोई redirect हो ही नहीं। असली इलाज ऊपर वाला flag है। `FORWARDED_ALLOW_IPS` इसी का environment-variable रूप है; `*` हर hop पर भरोसा करता है, जो सिर्फ़ तभी सही है जब proxy के अलावा कोई uvicorn तक न पहुँच सके। + ## Workers, और sticky किसे होना है {#workers-and-who-has-to-be-sticky} जब hostname जवाब देने लगे, तो उसके पीछे एक से ज़्यादा worker लगाएँ। इसके लिए SDK में कोई knob नहीं है; Starlette app को वैसे ही scale किया जाता है जैसे किसी भी ASGI app को, object किसी ऐसी चीज़ को सौंपकर जो fork करना जानती है: @@ -170,6 +186,7 @@ fan-out को इससे कोई मतलब नहीं कि stream ## सारांश {#recap} * बिना कुछ configure किए app सिर्फ़ उन्हीं requests का जवाब देता है जो localhost को भेजी गई हों। `transport_security=TransportSecuritySettings(allowed_hosts=[...], allowed_origins=[...])` ही go-live gate है: जब तक आप इसे नहीं देते, असली hostname के पीछे हर request `421` है और कारण सिर्फ़ server के log में है। +* TLS-terminating proxy के पीछे uvicorn को `--proxy-headers --forwarded-allow-ips=...` के साथ चलाएँ, वरना उसके redirects `http://` की ओर इशारा करते हैं और client उन्हें ठुकरा देता है। * 2026-07-28 पर कोई session नहीं है और load balancer के sticky होने के लिए कुछ नहीं। `stateless_http=True` सिर्फ़ legacy का knob है क्योंकि modern request उस flag के पढ़े जाने से पहले ही route होकर जवाब पा लेती है। * default `requestState` key `os.urandom(32)` है, हर process में अलग बनी हुई। कोई multi-round-trip retry जो दूसरे worker तक पहुँचे, `-32602` *"Invalid or expired requestState"* के साथ fail होती है। * इलाज है `RequestStateSecurity(keys=[...])` **और** हर instance पर एक ही server नाम। नाम ही token का default audience claim है। वही keys, वही नाम। diff --git a/i18n/hi/pages/run/index.md b/i18n/hi/pages/run/index.md index 7eaaee37..3c99980f 100644 --- a/i18n/hi/pages/run/index.md +++ b/i18n/hi/pages/run/index.md @@ -1,6 +1,6 @@ --- translation: - sections: [fea8d769ff9edeba, ce8e2ad42f29ef71, 0d705efb19cf99c2, 2fd7cf825e6d2b2c, 9adc400e8c88e854, 318893ad8e2e9924, 6b63ab96b34476c0] + sections: [fea8d769ff9edeba, ce8e2ad42f29ef71, 0d705efb19cf99c2, 9fd2357154a5b7e7, 9adc400e8c88e854, 318893ad8e2e9924, 6b63ab96b34476c0] tool: 1 --- # अपना server चलाना {#running-your-server} @@ -64,21 +64,26 @@ Inspector ठीक वही करता है जो असली host क --8<-- "docs_src/run/tutorial002.py" ``` -वह एक line Starlette app बनाती है और उसे uvicorn से serve करती है। Clients `http://127.0.0.1:3001/mcp` से जुड़ते हैं। +वह एक line Starlette app बनाती है और उसे uvicorn से serve करती है। clients `http://127.0.0.1:3001/mcp` से जुड़ते हैं। हर transport के अपने keyword arguments हैं, सब `run()` पर: -* `host` / `port`: कहाँ सुनना है। Default `127.0.0.1` और `8000`। -* `streamable_http_path`: MCP endpoint कहाँ रहता है। Default `/mcp`। +* `host` / `port`: कहाँ सुनना है। default `127.0.0.1` और `8000`। +* `streamable_http_path`: MCP endpoint कहाँ रहता है। default `/mcp`। * `json_response=True`: हर POST का जवाब SSE stream के बजाय एक अकेली JSON body से देना। उस body में सिर्फ़ response की जगह है, और कुछ नहीं, इसलिए जो tool request के बीच में client को वापस call करता है (`ctx.elicit()`, sampling), वह इस leg पर `NoBackChannelError` raise करता है, और चल रही call से जुड़े notifications (`ctx.report_progress()` का progress, per-call log messages) छोड़ दिए जाते हैं; standalone `GET` stream असंबंधित notifications अब भी ले जाती है। * `stateless_http=True`: हर request के लिए नया transport, कोई session tracking नहीं। -* `max_request_body_size`: स्वीकार की जाने वाली सबसे बड़ी request body, bytes में। Default 4 MiB है; इससे बड़ी requests +* `max_request_body_size`: स्वीकार की जाने वाली सबसे बड़ी request body, bytes में। default 4 MiB है; इससे बड़ी requests को parsing या session बनने से पहले ही HTTP 413 मिलता है। इसे तभी बढ़ाएँ जब जायज़ MCP messages उस आकार से बड़े हों। +* `session_idle_timeout`: कोई legacy session कितने seconds तक बिना किसी चल रही request के खाली रह सकता है, + इससे पहले कि server उसे बंद कर दे। default 1800। `None` इसे बंद कर देता है। देखें + [session का जीवनकाल और सीमाएँ](legacy-clients.md#session-lifetime-and-limits)। +* `max_sessions`: एक process एक साथ कितने legacy sessions रखता है। default 10 000। `None` + सीमा हटा देता है। इसकी जानकारी भी उसी section में है। * `event_store`, `retry_interval`, `transport_security`: resumability और DNS-rebinding से सुरक्षा। ये इंतज़ार कर सकते हैं, जब तक आप localhost के अलावा कहीं deploy न करें; `transport_security` की जानकारी **[Deploy & scale](deploy.md)** में है। !!! warning - Transport options `run()` को जाते हैं, `MCPServer(...)` को **नहीं**। Constructor बताता है कि + transport options `run()` को जाते हैं, `MCPServer(...)` को **नहीं**। constructor बताता है कि आपका server **क्या है**: name, version, instructions। `run()` बताता है कि वह कैसे serve होता है। इसे उल्टा करेंगे तो MCP के शामिल होने से पहले ही Python जवाब दे देता है: diff --git a/i18n/hi/pages/run/legacy-clients.md b/i18n/hi/pages/run/legacy-clients.md index c97b5413..b3477bf0 100644 --- a/i18n/hi/pages/run/legacy-clients.md +++ b/i18n/hi/pages/run/legacy-clients.md @@ -1,6 +1,6 @@ --- translation: - sections: [3d1663c18edc824c, d4fd37009a13f03d, af9f398a5a8b679a, 470c2dd144294d69, 8e45827e6d24e8c8, 91dfd0ce98ebb03c] + sections: [3d1663c18edc824c, 90956965ae6a1ca1, af9f398a5a8b679a, 5ce83b1f9d88da62, 0d9b5d13fffc94e5, 8e45827e6d24e8c8, 91dfd0ce98ebb03c] tool: 1 --- # legacy clients को serve करना {#serving-legacy-clients} @@ -21,15 +21,25 @@ SDK हर request को उसके `MCP-Protocol-Version` header के ह ## एक handler, दोनों पीढ़ियाँ {#one-handler-both-eras} -यह रहा एक tool जिसे user से कुछ पूछना है, और दोनों पीढ़ियों के client जो उसे call कर रहे हैं: +यह रहा एक tool जिसे user से कुछ पूछना है: -```python title="server.py" hl_lines="24 37-38" +```python title="server.py" hl_lines="21" --8<-- "docs_src/legacy_clients/tutorial001.py" ``` `reserve` को एक चीज़ चाहिए जो model ने नहीं दी: कितनी copies। tool यह बात `Annotated[..., Resolve(ask_quantity)]` से declare करता है (पूरी जानकारी **[Dependencies](../handlers/dependencies.md)** में है)। `reserve` में कहीं भी न किसी version का नाम है, न capability की जाँच, न कोई branch। -दोनों clients **एक ही समय पर** खुले हैं, उसी `mcp` object पर। `mode="legacy"` `initialize` handshake चलाता है: ठीक वही connection जो 2026 से पहले का client खोलता है। दूसरा client default लेता है और `2026-07-28` पर पहुँचता है। +इसे HTTP पर serve करें, और ये रहे दोनों पीढ़ियों के client जो इसे call कर रहे हैं: + +```console +uv run mcp run server.py --transport streamable-http +``` + +```python title="client.py" hl_lines="14-15" +--8<-- "docs_src/legacy_clients/tutorial001_client.py" +``` + +दोनों clients **एक ही समय पर** खुले हैं, उसी चल रहे server पर। `mode="legacy"` `initialize` handshake चलाता है: ठीक वही connection जो 2026 से पहले का client खोलता है। दूसरा client default लेता है और `2026-07-28` पर पहुँचता है। दूसरे terminal से `python client.py` चलाएँ: ```text 2025-11-25 {'result': "Reserved 2 of 'Dune'."} @@ -61,6 +71,40 @@ routing मुफ़्त है। session नहीं। client को छूटे हुए SSE events फिर से भेजना), session store नहीं। यह कभी किसी session को दूसरे process से पहुँच लायक नहीं बनाता। +## session की उम्र और सीमाएँ {#session-lifetime-and-limits} + +legacy session हमेशा के लिए नहीं जीता, और एक process अनगिनत sessions नहीं रखता। +इसे दो settings नियंत्रित करती हैं। दोनों `run()`, `streamable_http_app()` +और `Server.streamable_http_app()` पर keyword arguments हैं। modern (`2026-07-28`) connections और `stateless_http=True` +में sessions होते ही नहीं, इसलिए दोनों में से कोई setting उन पर लागू नहीं होती। + +| Setting | Default | यह क्या करती है | client को क्या दिखता है | बंद करने के लिए | +|---|---|---|---|---| +| `session_idle_timeout` | `1800` (30 मिनट) | ऐसे session को बंद कर देती है जिसमें इतनी देर से कुछ in flight न रहा हो। | `404 Session not found`। उसे फिर से `initialize` करना पड़ता है। | `None` | +| `max_sessions` | `10_000` | इतने से ज़्यादा होने पर नया session खोलने से मना कर देती है। मौजूदा sessions अछूते रहते हैं और कुछ भी evict नहीं होता। | `503 Too many open sessions`, JSON-RPC code `-32603` के साथ। | `None` | + +"in flight" किसे माना जाता है: + +* खुला `GET` stream। SDK के clients एक खुला रखते हैं, इसलिए जुड़े हुए client का session कभी + expire नहीं होता। +* ऐसी request जिसका जवाब अभी दिया जा रहा है। timeout से ज़्यादा देर चलने वाला tool call बीच में + नहीं रोका जाता, और उलटी गिनती उसके खत्म होने पर ही शुरू होती है। +* और कुछ नहीं। requests के बीच घड़ी चलती रहती है। session पर आई कोई भी request उसे फिर से शुरू कर देती है, + `ping` भी। एक बार session expire हो जाए, तो उसे कुछ भी वापस नहीं लाता। + +जो client अपना session `DELETE` से खत्म करता है, वह उसे तुरंत खाली कर देता है। जिस client की +पहली request मना कर दी गई हो, उसके साथ भी यही होता है। + +```python +mcp.run(transport="streamable-http", session_idle_timeout=None, max_sessions=50_000) +``` + +दोनों घटनाएँ server log में दिखती हैं। expiry `INFO` पर `Session idle timeout` के रूप में दिखती है। +मना किया गया open `WARNING` पर `Refusing to open a new session: sessions are already open` के रूप में। + +सीमाएँ हर process की अपनी हैं। चार workers के साथ ऊपरी सीमा `max_sessions` की चार गुना है, और हर +worker अपने sessions खुद expire करता है। + ## इकलौता switch: `stateless_http` {#the-one-knob-stateless_http} अगर stickiness ऐसी कीमत है जो आप चुकाना नहीं चाहते, तो ठीक एक चीज़ है जो आप बदल सकते हैं। @@ -86,7 +130,7 @@ routing मुफ़्त है। session नहीं। !!! check जानबूझकर गलत काम करें। `reserve` ठीक वही tool है जिसने अभी दोनों clients को serve किया। इसे - `stateless_http=True` के साथ deploy करें, वही दो clients HTTP पर जोड़ें, और हर एक से इसे call करें। + `stateless_http=True` के साथ deploy करें, वही दो clients जोड़ें, और हर एक से इसे call करें। modern client को अब भी `Reserved 2 of 'Dune'.` मिलता है। modern हिस्सा नहीं बदला। diff --git a/i18n/hi/pages/troubleshooting.md b/i18n/hi/pages/troubleshooting.md index bb0638ed..a8088dd3 100644 --- a/i18n/hi/pages/troubleshooting.md +++ b/i18n/hi/pages/troubleshooting.md @@ -1,6 +1,6 @@ --- translation: - sections: [2efaecdef109a5c5, fcacd3e66b8635a4, 25323d737dcf0261, 8a6e351ec756904d, 137454d469c867f5, 6392596bd6df54f0, 41126fa9c4fe432f, 480b6d7897e30ab4, d83bb682e708dde0, ebbed3449c499db4, 323ef84f6b4bebde, 30fd31be74169d9a, 656943c6cb567218, c2dc3b1007d2e987, 7cf5386b997d04e9, 0b59feed8384456e, 0cba47bae78d04eb, e4355f4c7cf4fb2e] + sections: [3d58228e81b99543, 170514ce901c4139, 17d61fad0a50d62b, 8a6e351ec756904d, 137454d469c867f5, 6392596bd6df54f0, 41126fa9c4fe432f, 480b6d7897e30ab4, d83bb682e708dde0, ebbed3449c499db4, 525cdf1755e29d4c, 30fd31be74169d9a, d2e88333d4f7841f, c2dc3b1007d2e987, d6eabf60cc366341, f798e815252852c2, 0cba47bae78d04eb, 2c218ba829abf74e] tool: 1 --- # समस्याएँ सुलझाना {#troubleshooting} @@ -13,6 +13,12 @@ translation: --8<-- "docs_src/troubleshooting/tutorial001.py" ``` +वे entries इस तक `http://localhost:8000/mcp` पर पहुँचती हैं, इसलिए इसे HTTP पर चलता छोड़ दें: + +```console +uv run mcp run server.py --transport streamable-http +``` + इस page पर quote किए गए errors असली हैं: SDK का अपना test suite इनमें से हर एक को reproduce करता है। ## `ExceptionGroup: unhandled errors in a TaskGroup (1 sub-exception)` {#exceptiongroup-unhandled-errors-in-a-taskgroup-1-sub-exception} @@ -23,7 +29,7 @@ translation: ```python async def main() -> None: - async with Client(mcp) as client: + async with Client("http://localhost:8000/mcp") as client: await client.read_resource("weather://Atlantis") ``` @@ -49,7 +55,7 @@ async def main() -> None: ```python async def main() -> None: - async with Client(mcp) as client: + async with Client("http://localhost:8000/mcp") as client: try: await client.read_resource("weather://Atlantis") except MCPError as e: @@ -67,7 +73,7 @@ async def main() -> None: ```python async def main() -> None: - client = Client(mcp) + client = Client("http://localhost:8000/mcp") tools = await client.list_tools() # RuntimeError ``` @@ -75,7 +81,7 @@ async def main() -> None: ```python async def main() -> None: - async with Client(mcp) as client: + async with Client("http://localhost:8000/mcp") as client: tools = await client.list_tools() ``` @@ -251,7 +257,7 @@ app = Starlette(routes=[Mount("/", app=mcp.streamable_http_app())], lifespan=lif ## `MCPError: Session not found` {#mcperror-session-not-found} -client ने जो `Mcp-Session-Id` भेजा उसे server नहीं पहचानता, लगभग हमेशा इसलिए कि server **restart** हुआ (या आपको किसी दूसरे instance पर route कर दिया गया)। sessions उसी एक process की memory में रहते हैं। +client ने जो `Mcp-Session-Id` भेजा उसे server नहीं पहचानता। या तो server **restart** हुआ (या आपको किसी दूसरे instance पर route कर दिया गया), या session **expire** हो गया क्योंकि `session_idle_timeout` तक, जो default रूप से 30 मिनट है, कुछ भी in flight नहीं था। [Session lifetime और limits](run/legacy-clients.md#session-lifetime-and-limits) देखें। sessions उसी एक process की memory में रहते हैं। खोजने को कोई server bug नहीं है। HTTP response एक `404` है जिसकी body JSON-RPC **है**, इसलिए ऊपर वाले `421` के उलट, python `Client` इसे आपको ज्यों का त्यों दिखाता है: @@ -261,9 +267,9 @@ client ने जो `Mcp-Session-Id` भेजा उसे server नहीं सुधार है reconnect करना: `async with Client(...)` block से बाहर निकलें और नए में enter करें, जो नया session negotiate करता है। लंबे समय तक चलने वाले client के लिए इसका मतलब है अपने calls के चारों ओर `MCPError` catch करना और इस message पर reconnect करना, न कि मरे हुए session के अंदर retry करते रहना। -अगर यह restart के **बिना** होता है, तो आप sticky sessions के बिना एक से ज़्यादा worker चला रहे हैं: हर worker की अपनी session table होती है, इसलिए गलत worker पर route हुई request यहीं आ गिरती है। वह पूरी कहानी और उसके दो सुधार (sticky routing, या `stateless_http=True`) **[Deploy और scale](run/deploy.md)** और **[legacy clients को serve करना](run/legacy-clients.md)** में हैं। +अगर यह restart के **बिना** होता है और client इतनी देर चुप भी नहीं रहा था, तो आप sticky sessions के बिना एक से ज़्यादा worker चला रहे हैं: हर worker की अपनी session table होती है, इसलिए गलत worker पर route हुई request यहीं आ गिरती है। वह पूरी कहानी और उसके दो सुधार (sticky routing, या `stateless_http=True`) **[Deploy और scale](run/deploy.md)** और **[legacy clients को serve करना](run/legacy-clients.md)** में हैं। -server operator के लिए इससे मेल खाती log line है `Rejected request with unknown or expired session ID: `। यह `INFO` पर log होती है, इसलिए आम `WARNING` threshold पर नहीं दिखती। deploy के ठीक बाद इसे झुंड में देखना सामान्य है; हर जुड़ा हुआ client reconnect कर रहा है। +server operator के लिए इससे मेल खाती log line है `Rejected request with unknown or expired session ID: `। यह `INFO` पर log होती है, इसलिए आम `WARNING` threshold पर नहीं दिखती। deploy के ठीक बाद इसे झुंड में देखना सामान्य है; हर जुड़ा हुआ client reconnect कर रहा है। जब session expire हुआ हो, तो उस line से पहले `Session idle timeout` आती है, वह भी `INFO` पर। ## `MCPError: Method not found` {#mcperror-method-not-found} @@ -275,7 +281,13 @@ server operator के लिए इससे मेल खाती log line आपका server user से कुछ पूछना चाहता है, और इस client ने कभी कहा ही नहीं कि उससे पूछा जा सकता है। -जब जुड़े हुए client ने form elicitation declare नहीं किया हो, तो elicitation resolver शुरू में ही मना कर देता है, और `e.error.data` ठीक-ठीक बताता है कि क्या गायब है: +यह Bistro book करने से पहले पूछता है, एक resolver के ज़रिए: + +```python title="server.py" hl_lines="15-17 21" +--8<-- "docs_src/troubleshooting/tutorial007.py" +``` + +Weather server की जगह इसे serve करें और ऐसे client से `book_table` call करें जिसने कोई `elicitation_callback` नहीं दिया। resolver शुरू में ही मना कर देता है, क्योंकि जुड़े हुए client ने form elicitation कभी declare नहीं किया, और `e.error.data` ठीक-ठीक बताता है कि क्या गायब है: ```json { @@ -289,7 +301,7 @@ server operator के लिए इससे मेल खाती log line ```python async def main() -> None: - async with Client(mcp, elicitation_callback=handle_elicitation) as client: + async with Client("http://localhost:8000/mcp", elicitation_callback=handle_elicitation) as client: result = await client.call_tool("book_table", {"date": "Friday"}) ``` @@ -314,14 +326,14 @@ async def main() -> None: आपके handler ने request के बीच में client तक पहुँचने की कोशिश की, ऐसे connection पर जिसके call में server की ओर से request ले जाने वाला कोई channel नहीं है। तीन server configurations हैं जो किसी call को इस हालत में डालते हैं। -**`2026-07-28` connection: कोई भी transport, हमेशा।** आधुनिक protocol में server-initiated requests हैं ही नहीं, इसलिए server कुछ भेजे जाने से पहले ही मना कर देता है। tool के अंदर `ctx.elicit()` इससे टकराने का classic तरीका है (पहले ही in-memory test पर, क्योंकि `Client(server)` बिना कहे `2026-07-28` negotiate करता है), और `elicitation_callback=` देने से कुछ नहीं बदलता, क्योंकि client तक कभी कोई request पहुँचती ही नहीं जिसका वह जवाब दे: +**`2026-07-28` connection: कोई भी transport, हमेशा।** आधुनिक protocol में server-initiated requests हैं ही नहीं, इसलिए server कुछ भेजे जाने से पहले ही मना कर देता है। tool के अंदर `ctx.elicit()` इससे टकराने का classic तरीका है, आम तौर पर उस tool के पहले ही in-memory **[test](get-started/testing.md)** में, क्योंकि `Client(mcp)` बिना कहे `2026-07-28` negotiate करता है। `elicitation_callback=` देने से कुछ नहीं बदलता, क्योंकि client तक कभी कोई request पहुँचती ही नहीं जिसका वह जवाब दे: ```python title="server.py" hl_lines="16" --8<-- "docs_src/troubleshooting/tutorial006.py" ``` ```python -async def main() -> None: +async def test_book_table() -> None: async with Client(mcp) as client: await client.call_tool("book_table", {"date": "Friday"}) ``` @@ -363,7 +375,7 @@ client ने जो `requestState` token वापस echo किया, server ```python async def main() -> None: - async with Client(mcp) as client: + async with Client("http://localhost:8000/mcp") as client: await client.call_tool("forecast", {"city": "London"}, request_state="round-1-from-worker-a") ``` @@ -416,7 +428,7 @@ mcp = MCPServer("Weather", request_state_security=RequestStateSecurity(keys=[key * server log में `Tool already exists:` ही इकलौता संकेत है कि एक ही नाम के दो tools सिमटकर एक रह गए। * एक 421, तीन रूप: `Server returned an error response` (python `Client`), `421 Misdirected Request` / `Invalid Host header` (बाकी सब), `Invalid Host header: ` (server log)। सुधार: `transport_security=TransportSecuritySettings(allowed_hosts=[...])`। * `Task group is not initialized` -> mounted app जिसके host lifespan ने कभी `mcp.session_manager.run()` में enter नहीं किया। -* `Session not found` -> server restart हुआ; reconnect करें। +* `Session not found` -> server restart हुआ या session expire हो गया (`session_idle_timeout`); reconnect करें। * `Cannot send 'elicitation/create': ... no back-channel ...` -> `ctx.elicit()` को server-to-client channel चाहिए: `2026-07-28` connection में वह कभी नहीं होता, `stateless_http=True` legacy वाला छीन लेता है, और `json_response=True` request-scoped वाला। resolver इस्तेमाल करें (legacy client को ऐसा server भी चाहिए जो channel रखता हो)। इसका पड़ोसी `Method not found` ऐसे method की request है जो दूसरी side के protocol revision में है ही नहीं। * `Client did not declare the form elicitation capability ...` और `Elicitation not supported` -> client में `elicitation_callback=` गायब है। * `Invalid or expired requestState` wire पर कभी नहीं बताता कि क्यों। server log बताता है; `unknown key` का मतलब है workers के बीच `RequestStateSecurity(keys=[...])` साझा करें। diff --git a/i18n/hi/pages/whats-new.md b/i18n/hi/pages/whats-new.md index 3550445d..7178b87f 100644 --- a/i18n/hi/pages/whats-new.md +++ b/i18n/hi/pages/whats-new.md @@ -1,6 +1,6 @@ --- translation: - sections: [cfe01c0c5863dfa2, 1c58c5cfcc37d455, a7392996acf1ad8f, 875eb2889263424e] + sections: [cfe01c0c5863dfa2, 0dbb68d8b210177b, 80cf193023af3ed4, 875eb2889263424e] tool: 1 --- # v2 में नया क्या है {#whats-new-in-v2} @@ -42,11 +42,11 @@ tool को जो कुछ चाहिए, वह सब model से नह v1 आपको तीन nested परतें थमाता था: raw streams देने वाला transport context manager, उनके चारों ओर लिपटा `ClientSession`, और हाथ से call किया जाने वाला `await session.initialize()`। v2 में एक ही object है: -```python title="client.py" hl_lines="14-18" ---8<-- "docs_src/client/tutorial001.py" +```python title="client.py" hl_lines="7-11" +--8<-- "docs_src/client/tutorial001_client.py" ``` -`Client` server object लेता है (in memory, कोई transport नहीं: testing वाली कहानी), URL (Streamable HTTP), `StdioServerParameters` (stdio subprocess), या `sse_client(...)` जैसा कोई भी दूसरा transport context manager। `async with` में प्रवेश करते ही connect होता है और protocol version negotiate होता है, server चाहे जिस पीढ़ी का हो; उसके बाद `client.server_capabilities` और `client.protocol_version` बस उपलब्ध रहते हैं, और जब server अपनी पहचान बताता है तो `client.server_info` भी (यह अब `Implementation | None` है, क्योंकि 2026 पीढ़ी में identity optional है)। v1 में register किए गए sampling और elicitation callbacks अब भी काम करते हैं (उनकी bodies में वही snake_case attribute rename दिखता है जो इस page की हर चीज़ में), वे अब 2026-style requests-inside-results (नीचे) का जवाब भी देते हैं, और वे एक-एक करके नहीं, बल्कि concurrently चलते हैं। जिसे low-level surface चाहिए, उसके लिए `ClientSession` अब भी नीचे मौजूद है, और `client.session` उसे आपको देता है; वह भी बदला है (वह नए dispatcher engine पर चलता है, और उसके कुछ अपने signatures बदले हैं), इसलिए नीचे उतरने से पहले **[Migration Guide](migration.md#clientsession-now-runs-on-jsonrpcdispatcher-basesession-removed)** पढ़ें। +`Client` URL लेता है (Streamable HTTP), `StdioServerParameters` (stdio subprocess), `sse_client(...)` जैसा कोई भी दूसरा transport context manager, या, tests में, खुद server object (in memory, कोई transport नहीं)। `async with` में प्रवेश करते ही connect होता है और protocol version negotiate होता है, server चाहे जिस पीढ़ी का हो; उसके बाद `client.server_capabilities` और `client.protocol_version` बस उपलब्ध रहते हैं, और जब server अपनी पहचान बताता है तो `client.server_info` भी (यह अब `Implementation | None` है, क्योंकि 2026 पीढ़ी में identity optional है)। v1 में register किए गए sampling और elicitation callbacks अब भी काम करते हैं (उनकी bodies में वही snake_case attribute rename दिखता है जो इस page की हर चीज़ में), वे अब 2026-style requests-inside-results (नीचे) का जवाब भी देते हैं, और वे एक-एक करके नहीं, बल्कि concurrently चलते हैं। जिसे low-level surface चाहिए, उसके लिए `ClientSession` अब भी नीचे मौजूद है, और `client.session` उसे आपको देता है; वह भी बदला है (वह नए dispatcher engine पर चलता है, और उसके कुछ अपने signatures बदले हैं), इसलिए नीचे उतरने से पहले **[Migration Guide](migration.md#clientsession-now-runs-on-jsonrpcdispatcher-basesession-removed)** पढ़ें। **[The Client](client/index.md)** इसका परिचय देता है, **[Client transports](client/transports.md)** connection के चारों रूप समझाता है, **[Client callbacks](client/callbacks.md)** खुद callbacks को, और **[Testing](get-started/testing.md)** वह in-memory pattern दिखाता है जो v1 के `create_connected_server_and_client_session()` helper की जगह लेता है। @@ -153,7 +153,7 @@ renames खुद अपनी घोषणा करते हैं। ये * `McpError`, जिसका नाम बदलकर **`MCPError`** हुआ, सीधे `(code, message, data)` constructor के साथ। * `MCPServer.get_context()`, `mount_path=`, और lowlevel `Server` के decorator methods, ContextVar और handler dicts। -## Protocol: 2025-11-25 से 2026-07-28 {#the-protocol-2025-11-25-to-2026-07-28} +## protocol: 2025-11-25 से 2026-07-28 {#the-protocol-2025-11-25-to-2026-07-28} v2 2026-07-28 revision implement करता है, और यह **दोनों** revisions एक साथ serve करता है: वही `streamable_http_app()` (और वही stdio server) 2025 पीढ़ी के client के `initialize` और 2026 पीढ़ी के client की requests, दोनों का जवाब देता है, बिना कुछ configure किए, बिना कोई flag पलटे, और बिना अलग deployment के। नया revision serve करने से पुराने revision वाला client बीच में नहीं छूटता। आगे वह है जो नया revision खुद बदलता है। @@ -163,19 +163,23 @@ v2 2026-07-28 revision implement करता है, और यह **दोन Streamable HTTP पर 2026 path में कोई `Mcp-Session-Id` नहीं है, और operational headline यही है: **कोई चीज़ modern request को किसी worker से नहीं बाँधती**, इसलिए सादे round-robin load balancer के पीछे कोई भी replica उसका जवाब दे सकता है। दो ईमानदार शर्तें। आपके 2025 पीढ़ी के clients (आज ज़्यादातर clients यही हैं) अब भी sessions खोलते हैं और उन्हें अब भी वही stickiness चाहिए जो v1 पर चाहिए थी; उनके लिए कुछ नहीं बदलता। और एक चीज़ जो **multi-round-trip** retry को workers के पार ले जानी होती है, वह उसका sealed `request_state` है, जिसकी default key हर process में अलग बनती है, इसलिए scaled-out deployment `RequestStateSecurity(keys=[...])` pass करता है। (`stateless_http=True` का इससे लेना-देना नहीं: वह सिर्फ़ यह तय करता है कि 2025 पीढ़ी के clients कैसे serve हों, और 2026 traffic उसे कभी नहीं पढ़ता; अगर आपने v1 में उसे पहले से set किया है, तो कुछ नहीं बदलता।) -इसका client वाला पहलू **[Protocol versions](protocol-versions.md)** है, operator की checklist **[Deploy & scale](run/deploy.md)** है (Host allowlist, `request_state` key, replicas के पार notifications), और दोनों पीढ़ियाँ एक साथ serve करने की कहानी **[Legacy clients को serve करना](run/legacy-clients.md)** है। +इसका client वाला पहलू **[Protocol versions](protocol-versions.md)** है, operator की checklist **[Deploy & scale](run/deploy.md)** है (Host allowlist, `request_state` key, replicas के पार notifications), और दोनों पीढ़ियाँ एक साथ serve करने की कहानी **[legacy clients को serve करना](run/legacy-clients.md)** है। -### Server client को call नहीं कर सकता: multi-round-trip requests {#the-server-cannot-call-the-client-multi-round-trip-requests} +### server client को call नहीं कर सकता: multi-round-trip requests {#the-server-cannot-call-the-client-multi-round-trip-requests} 2026-07-28 पर हर server-initiated request हट गई है: push elicitation, sampling, `roots/list`। 2026 connection पर उनके लिए कोई channel नहीं है, इसलिए `ctx.elicit()` और `ctx.session.create_message()` वहाँ `NoBackChannelError` के साथ fail होते हैं (legacy clients के लिए वे अब भी काम करते हैं)। -इसका विकल्प call को पलट देता है। जिस tool को user से कुछ चाहिए, वह सवाल **लौटाता** है (`InputRequiredResult`), client उन्हीं callbacks से उसका जवाब देता है जो उसके पास हमेशा से थे, और call को जवाबों के साथ retry किया जाता है। `Client` यह loop आपके लिए चलाता है। Server पर आप result शायद ही कभी खुद बनाते हैं, क्योंकि एक **[dependency](handlers/dependencies.md)** यह कर देती है: parameter को `Resolve(ask_quantity)` से annotate करें, जहाँ `ask_quantity` आपका लिखा साधारण function है, और SDK उसी mechanism से पूछता है जिसे connection support करता है, legacy session पर live elicitation request या 2026 पर multi-round-trip। एक tool body, दोनों पीढ़ियाँ: +इसका विकल्प call को पलट देता है। जिस tool को user से कुछ चाहिए, वह सवाल **लौटाता** है (`InputRequiredResult`), client उन्हीं callbacks से उसका जवाब देता है जो उसके पास हमेशा से थे, और call को जवाबों के साथ retry किया जाता है। `Client` यह loop आपके लिए चलाता है। server पर आप result शायद ही कभी खुद बनाते हैं, क्योंकि एक **[dependency](handlers/dependencies.md)** यह कर देती है: parameter को `Resolve(ask_quantity)` से annotate करें, जहाँ `ask_quantity` आपका लिखा साधारण function है, और SDK उसी mechanism से पूछता है जिसे connection support करता है, legacy session पर live elicitation request या 2026 पर multi-round-trip। एक tool body, दोनों पीढ़ियाँ: -```python title="dual_era.py" hl_lines="24 37-38" +```python title="server.py" hl_lines="21" --8<-- "docs_src/legacy_clients/tutorial001.py" ``` -वह file पूरी बात एक जगह कह देती है: एक server, एक `Resolve`-backed tool, और एक legacy client तथा एक modern client, दोनों को अपना जवाब मिलता है, in memory। **[Multi-round-trip requests](handlers/multi-round-trip.md)** mechanism समझाता है (`request_state` समेत, जिसे SDK आपके लिए seal और verify करता है); पूछने का हिस्सा **[Elicitation](handlers/elicitation.md)** में है। +```python title="client.py" hl_lines="14-15" +--8<-- "docs_src/legacy_clients/tutorial001_client.py" +``` + +ये दो files पूरी बात कह देती हैं: एक server, एक `Resolve`-backed tool, और एक legacy client तथा एक modern client, दोनों को उसी चलते हुए server से अपना जवाब मिलता है (**[legacy clients को serve करना](run/legacy-clients.md)** इन्हें कदम दर कदम समझाता है)। **[Multi-round-trip requests](handlers/multi-round-trip.md)** mechanism समझाता है (`request_state` समेत, जिसे SDK आपके लिए seal और verify करता है); पूछने का हिस्सा **[Elicitation](handlers/elicitation.md)** में है। !!! warning "यही वह एक जगह है जहाँ port किए गए v1 server का व्यवहार बदलता है" आपके अपने tests इससे सबसे पहले टकराते हैं: `Client(mcp)` default रूप से आपके v2 server के सामने 2026-07-28 negotiate करता है, @@ -183,28 +187,28 @@ Streamable HTTP पर 2026 path में कोई `Mcp-Session-Id` नही `Resolve(...)` parameter में ले जाएँ (हर पीढ़ी में चलने वाला), या अगर आपको वाकई push व्यवहार चाहिए तो test client को `mode="legacy"` पर pin करें। -### Roots, sampling और protocol logging deprecated हैं; `ping` हटा दिया गया {#roots-sampling-and-protocol-logging-are-deprecated-ping-is-removed} +### roots, sampling और protocol logging deprecated हैं; `ping` हटा दिया गया {#roots-sampling-and-protocol-logging-are-deprecated-ping-is-removed} [SEP-2577](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577) तीन पूरी **capabilities** को हर protocol version पर deprecated करता है: roots, sampling और MCP-level logging (`ctx.info()` वगैरह)। यह ऊपर के गायब back-channel से अलग धुरी है; deprecated सिर्फ़ सलाह है, 2025 पीढ़ी के sessions के सामने सब कुछ काम करता रहता है, और wire पर कुछ नहीं बदलता। जो आपको दिखता है वह `MCPDeprecationWarning` है, जो `UserWarning` है, इसलिए default रूप से print होता है; मानकर चलें कि upgrade के बाद आपका पहला `ctx.info(...)` यही कहेगा। -`ping` ज़्यादा सख्त है: deprecated नहीं, protocol से हटा दिया गया। Deprecated features के दो standalone methods भी 2026-07-28 पर इसी तरह हटाए गए हैं, `logging/setLevel` और client का `notifications/roots/list_changed`, और progress notifications अब सिर्फ़ server-to-client हैं। +`ping` ज़्यादा सख्त है: deprecated नहीं, protocol से हटा दिया गया। deprecated features के दो standalone methods भी 2026-07-28 पर इसी तरह हटाए गए हैं, `logging/setLevel` और client का `notifications/roots/list_changed`, और progress notifications अब सिर्फ़ server-to-client हैं। **[Deprecated features](deprecated.md)** में पूरी table, हर एक का विकल्प, और legacy clients को serve करते समय शांत log चाहिए तो one-line filter है। -### Change notifications एक stream बन जाते हैं {#change-notifications-become-one-stream} +### change notifications एक stream बन जाते हैं {#change-notifications-become-one-stream} -2026-07-28 पर standalone HTTP GET stream और `resources/subscribe` की जगह `subscriptions/listen` लेता है: client एक long-lived stream खोलता है और बताता है कि उसे किस तरह के notifications चाहिए। `MCPServer` इसे बिना कुछ configure किए serve करता है; आप `await ctx.notify_resource_updated(uri)` (और `notify_tools_changed()`, वगैरह) से publish करते हैं, एक middleware हर caller के लिए listen request ठुकरा सकता है, और multi-replica deployments एक साझा `SubscriptionBus` लगाते हैं। Client पर `async with client.listen(...)` stream खोलता है: filter keyword arguments के रूप में जाता है, typed change events वापस आते हैं, और `sub.honored` वह subset है जिसे server deliver करने पर राज़ी हुआ। +2026-07-28 पर standalone HTTP GET stream और `resources/subscribe` की जगह `subscriptions/listen` लेता है: client एक long-lived stream खोलता है और बताता है कि उसे किस तरह के notifications चाहिए। `MCPServer` इसे बिना कुछ configure किए serve करता है; आप `await ctx.notify_resource_updated(uri)` (और `notify_tools_changed()`, वगैरह) से publish करते हैं, एक middleware हर caller के लिए listen request ठुकरा सकता है, और multi-replica deployments एक साझा `SubscriptionBus` लगाते हैं। client पर `async with client.listen(...)` stream खोलता है: filter keyword arguments के रूप में जाता है, typed change events वापस आते हैं, और `sub.honored` वह subset है जिसे server deliver करने पर राज़ी हुआ। -Publishing और serving **[Subscriptions](handlers/subscriptions.md)** में है, देखने वाला छोर **[इसके Clients वाले जुड़वाँ page](client/subscriptions.md)** में, और bus **[Deploy & scale](run/deploy.md)** में। +publishing और serving **[Subscriptions](handlers/subscriptions.md)** में है, देखने वाला छोर **[इसके Clients वाले जुड़वाँ page](client/subscriptions.md)** में, और bus **[Deploy & scale](run/deploy.md)** में। ### बाकी, फटाफट {#the-rest-quickly} -* **Identity optional, per-message metadata है।** Request-side `clientInfo` `_meta` key optional है (ज़रूरी जोड़ी `protocolVersion` + `clientCapabilities` है), और `serverInfo` `server/discover` result body से बाहर चला गया: servers इसके बजाय उसे हर 2026 पीढ़ी के result के `_meta` में stamp करते हैं ([spec #3002](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/3002))। SDK हमेशा stamp करता है; जब server अपनी पहचान नहीं बताता (उदाहरण के लिए, किसी middleware ने key हटा दी) तो `client.server_info` `None` होता है। **[The low-level Server](advanced/low-level-server.md)** wire पर stamp दिखाता है। -* **Requests bodies parse किए बिना route हो सकती हैं।** Modern HTTP requests `Mcp-Method` ले जाती हैं (और तीन tool जैसी calls के लिए `Mcp-Name`); `x-mcp-header` से annotate की गई tool input-schema property को `Mcp-Param-*` header में mirror किया जाता है और server उसे cross-check करता है ([SEP-2243](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2243))। Gateways और rate limiters सिर्फ़ headers पर route कर सकते हैं; नियम **[Migration Guide](migration.md#servers-validate-mcp-param-headers-against-the-request-body-sep-2243)** में हैं। -* **Results cache hints ले जाते हैं।** List और read results `ttlMs` और `cacheScope` declare करते हैं ([SEP-2549](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2549)); आप उन्हें `cache_hints=` से हर method के लिए set करते हैं, और `Client` built-in response cache के साथ उनका मान रखता है। जो server कोई hints नहीं भेजता (हर pre-2026 server), उसे जस का तस, uncached traffic दिखता है। **[Caching hints](client/caching.md)**। -* **Extensions first class हैं।** Servers और clients reverse-DNS identifiers के नीचे optional capability bundles declare करते हैं ([SEP-2133](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2133)); built-in `Apps` extension (MCP Apps) reference है। **[Extensions](advanced/extensions.md)** और **[MCP Apps](advanced/apps.md)**। -* **Error codes standardized हो गए।** गायब resource `-32602` है, `error.data` में URI के साथ, और नए spec-reserved codes `-32020` (header mismatch), `-32021` (ज़रूरी capability गायब) और `-32022` (unsupported protocol version) के रूप में दिखते हैं। **[Troubleshooting](troubleshooting.md)** ठीक उन्हीं messages के हिसाब से व्यवस्थित है। -* **Authorization को गलत पकड़ना अब मुश्किल है।** Client authorization code के साथ लौटे `iss` को validate करता है ([RFC 9207](https://datatracker.ietf.org/doc/html/rfc9207); आपका `callback_handler` अब `AuthorizationCodeResult` लौटाता है), register करते समय `application_type` भेजता है, और credentials को कभी किसी दूसरे authorization server के सामने replay नहीं करता। Enterprise कोने में नया: [SEP-990](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/990) identity-assertion flow। **[Migration Guide](migration.md)** हर OAuth बदलाव की सूची देता है; pages **[OAuth for clients](client/oauth-clients.md)** और **[Identity assertion](client/identity-assertion.md)** हैं। +* **identity optional, per-message metadata है।** request-side `clientInfo` `_meta` key optional है (ज़रूरी जोड़ी `protocolVersion` + `clientCapabilities` है), और `serverInfo` `server/discover` result body से बाहर चला गया: servers इसके बजाय उसे हर 2026 पीढ़ी के result के `_meta` में stamp करते हैं ([spec #3002](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/3002))। SDK हमेशा stamp करता है; जब server अपनी पहचान नहीं बताता (उदाहरण के लिए, किसी middleware ने key हटा दी) तो `client.server_info` `None` होता है। **[The low-level Server](advanced/low-level-server.md)** wire पर stamp दिखाता है। +* **requests bodies parse किए बिना route हो सकती हैं।** modern HTTP requests `Mcp-Method` ले जाती हैं (और तीन tool जैसी calls के लिए `Mcp-Name`); `x-mcp-header` से annotate की गई tool input-schema property को `Mcp-Param-*` header में mirror किया जाता है और server उसे cross-check करता है ([SEP-2243](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2243))। gateways और rate limiters सिर्फ़ headers पर route कर सकते हैं; नियम **[Migration Guide](migration.md#servers-validate-mcp-param-headers-against-the-request-body-sep-2243)** में हैं। +* **results cache hints ले जाते हैं।** list और read results `ttlMs` और `cacheScope` declare करते हैं ([SEP-2549](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2549)); आप उन्हें `cache_hints=` से हर method के लिए set करते हैं, और `Client` built-in response cache के साथ उनका मान रखता है। जो server कोई hints नहीं भेजता (हर pre-2026 server), उसे जस का तस, uncached traffic दिखता है। **[Caching hints](client/caching.md)**। +* **extensions first class हैं।** servers और clients reverse-DNS identifiers के नीचे optional capability bundles declare करते हैं ([SEP-2133](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2133)); built-in `Apps` extension (MCP Apps) reference है। **[Extensions](advanced/extensions.md)** और **[MCP Apps](advanced/apps.md)**। +* **error codes standardized हो गए।** गायब resource `-32602` है, `error.data` में URI के साथ, और नए spec-reserved codes `-32020` (header mismatch), `-32021` (ज़रूरी capability गायब) और `-32022` (unsupported protocol version) के रूप में दिखते हैं। **[Troubleshooting](troubleshooting.md)** ठीक उन्हीं messages के हिसाब से व्यवस्थित है। +* **authorization को गलत पकड़ना अब मुश्किल है।** client authorization code के साथ लौटे `iss` को validate करता है ([RFC 9207](https://datatracker.ietf.org/doc/html/rfc9207); आपका `callback_handler` अब `AuthorizationCodeResult` लौटाता है), register करते समय `application_type` भेजता है, और credentials को कभी किसी दूसरे authorization server के सामने replay नहीं करता। enterprise कोने में नया: [SEP-990](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/990) identity-assertion flow। **[Migration Guide](migration.md)** हर OAuth बदलाव की सूची देता है; pages **[OAuth for clients](client/oauth-clients.md)** और **[Identity assertion](client/identity-assertion.md)** हैं। * **हर server traceable है।** OpenTelemetry middleware के रूप में default रूप से चालू आता है: हर request को एक server span मिलता है, और जब तक process कोई exporter configure न करे, इसकी कोई लागत नहीं। जब दोनों छोर SDK चलाते हैं, तो client `_meta` में W3C trace context भी propagate करता है, इसलिए traces जुड़ जाते हैं। **[OpenTelemetry](run/opentelemetry.md)**। ## v1 से upgrade कर रहे हैं? {#upgrading-from-v1} diff --git a/i18n/ja/pages/advanced/apps.md b/i18n/ja/pages/advanced/apps.md index 497d06be..d1b4bf58 100644 --- a/i18n/ja/pages/advanced/apps.md +++ b/i18n/ja/pages/advanced/apps.md @@ -1,6 +1,6 @@ --- translation: - sections: [0355618e5f4d5fe4, 1821eaf50f2d0b64, 82e0b28ebd3abf5a, 8ac39614c094f2d0, dab6ff945501ab2a, bd5565c3b2d4f959, 96819ce3d63a0487] + sections: [0355618e5f4d5fe4, 0fefa31fb7c7b585, 5c53e7487c9c70cc, 8ac39614c094f2d0, dab6ff945501ab2a, bd5565c3b2d4f959, 96819ce3d63a0487] tool: 1 --- # MCP Apps {#mcp-apps} @@ -18,7 +18,7 @@ SDK はこれを組み込みの `Apps` 拡張(`io.modelcontextprotocol/ui`) ## 見た目のある時計 {#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" ``` @@ -39,11 +39,31 @@ HTML 自体はホストの `postMessage` を待ち受けて結果を表示しま モデルが読むのは `content` で、iframe は人間のためのものです。UI に対応したホストでもテキストの結果はモデルに渡されますし、テキスト専用のクライアントはそれ「だけ」を受け取ります。ですから定番のパターンは「1 つのツール、2 つの答え」です。もう一度 `get_time` を見てください。 -```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)` が `True` になるのは、クライアントが `io.modelcontextprotocol/ui` 拡張を宣言し、**かつ** `mimeTypes` 設定に `text/html;profile=mcp-app` を含めている場合だけです。このフィールドは必須なので、省略したクライアントは該当しません。同じファイルの `main()` が宣言しているのはまさにこれです。ネゴシエーションのクライアント側であり、その結果リッチな答えが返ってきます。 +`client_supports_apps(ctx)` が `True` になるのは、クライアントが `io.modelcontextprotocol/ui` 拡張を宣言し、**かつ** `mimeTypes` 設定に `text/html;profile=mcp-app` を含めている場合だけです。このフィールドは必須なので、省略したクライアントは該当しません。ネゴシエーションのクライアント側は次のとおりです。 + +```python title="client.py" hl_lines="8 12" +--8<-- "docs_src/apps/tutorial001_client.py" +``` + +`server.py` を HTTP で提供し、別のターミナルからクライアントを実行してください。 + +```console +uv run mcp run server.py --transport streamable-http +``` + +```console +python client.py +``` + +```text +2026-06-26T12:00:00Z +``` + +リッチな答えが返ってきました。`Client` の呼び出しから `extensions=[APPS_SUPPORT]` を外すと、同じプログラムは代わりに `The time is 2026-06-26T12:00:00Z.` と表示します。テキスト専用のクライアントが目にするのは、これがすべてです。 !!! warning `"[Rendered UI]"` のようなプレースホルダーを唯一のコンテンツとして返さないでください。フォールバックのテキストが役に立たなければ、そのツールはテキスト専用のすべてのクライアントにとっても、モデル自身にとっても役に立ちません。きちんと文を書いてください。 diff --git a/i18n/ja/pages/advanced/extensions.md b/i18n/ja/pages/advanced/extensions.md index 5734fafd..8bf2d07d 100644 --- a/i18n/ja/pages/advanced/extensions.md +++ b/i18n/ja/pages/advanced/extensions.md @@ -1,6 +1,6 @@ --- translation: - sections: [05891e7cc1938a13, b3c01a6af28c51ee, 7ffc91f5e38bdfe0, 717d3f235a8333a7, f471a13b2fe5d737, ed6af2df4b656dff] + sections: [05891e7cc1938a13, b3c01a6af28c51ee, 6eba6ce094f4417e, 125f28588c85dd7b, ddd8969b698ca11c, ed6af2df4b656dff] tool: 1 --- # 拡張機能 {#extensions} @@ -49,7 +49,7 @@ TypeError: Stamps.identifier must be a `vendor-prefix/name` string 役に立つ最小の拡張機能は、ツール 1 つと設定マップ 1 つです。 -```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" ``` @@ -57,17 +57,23 @@ TypeError: Stamps.identifier must be a `vendor-prefix/name` string * `settings()` は `capabilities.extensions["com.example/stamps"]` にアドバタイズされる値です。設定なしで拡張機能をアドバタイズするには `{}`(デフォルト)を返してください。 * 拡張機能がサーバーを受け取ることはありません。提供するものをデータとして宣言し、`MCPServer` がそれを消費します。書き換えられる `self.server` はありません。 -そして `main()` がその証明です。`mcp` に直接つなぐインメモリのクライアントです。 +HTTP で配信すれば、クライアントがその証明になります。 -```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" +``` + +このページの `server.py` はどれもこのコマンドで配信し、`client.py` はどれもその横で、2 つ目のターミナルから `python client.py` で実行します。 + ### 独自メソッドの提供 {#serving-your-own-methods} 拡張機能は**新しいリクエストメソッド**を登録できます。仕様のメソッドと並んで配信される、独自の動詞(verb)です。 -```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" ``` @@ -83,14 +89,15 @@ TypeError: Stamps.identifier must be a `vendor-prefix/name` string ### クライアント側 {#the-client-side} -同じファイルの `main()` に、クライアント側の話がすべて、その両半分とも入っています。 +クライアントは独立したプログラムで、クライアント側の話の両半分を担っています。 -```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)])` が拡張機能を宣言します。宣言は `ClientCapabilities.extensions` になります。2026-07-28 接続では、このマップはリクエストごとの `_meta` エンベロープで運ばれるため、サーバーは**すべての**リクエストでそれを見ます。レガシー接続では `initialize` ハンドシェイクに載ります。サーバーのコードはどちらでも気にしません。`require_client_extension(ctx, ...)` と `ctx.session.check_client_capability(...)` は、どちらの経路でも正しい情報源を読みます。 * ベンダーメソッドは 1 層下がって `client.session.send_request(...)` を使います。`Client` がファーストクラスのメソッドを増やすのは仕様の動詞に対してだけです。`send_request` はどんな `Request` サブクラスも受け付けるため、ベンダーリクエストはそのまま渡せます。 +* `SearchRequest` と、それが運ぶ 2 つのモデルは拡張機能の通信上の契約なので、クライアントは自分でそれらを宣言します。公開された拡張機能なら、両側がインポートするパッケージに含めて配布するでしょう。 ### `tools/call` のインターセプト {#intercepting-toolscall} @@ -109,12 +116,18 @@ TypeError: Stamps.identifier must be a `vendor-prefix/name` string ## クライアント拡張機能を使う {#using-a-client-extension} -**クライアント拡張機能**は、同じ契約を利用する側から見たものです。1 つの識別子の下にまとめられたクライアント側の振る舞い一式です。インスタンスを `Client(extensions=[...])` に渡し、通常どおりツールを呼び出します。 +**クライアント拡張機能**は、同じ契約を利用する側から見たものです。1 つの識別子の下にまとめられたクライアント側の振る舞い一式です。ここでのサーバーは `buy` に対して、品物ではなく引き換え用のレシートで答えます。ただし、拡張機能を宣言したクライアントに対してだけです。 -```python title="client.py" hl_lines="66-68" +```python title="server.py" hl_lines="22-25" --8<-- "docs_src/extensions/tutorial006.py" ``` +クライアントでは、インスタンスを `Client(extensions=[...])` に渡し、通常どおりツールを呼び出します。 + +```python title="client.py" hl_lines="33-35" +--8<-- "docs_src/extensions/tutorial006_client.py" +``` + `call_tool("buy", ...)` は、他のすべての呼び出しと同様にプレーンな `CallToolResult` を返します。拡張機能が変えたのは次の点です。サーバーは `buy` に対して、最終結果の代わりに `receipt` という**結果の形状**で答えられるようになり、`call_tool` が戻る前に `Receipts` がそれを完了させます(ここでは後続の呼び出しでレシートを引き換えます)。呼び出し側のコードは何も変わりません。 拡張機能を外せば、このどれも存在しません。サーバーのゲートは宣言しなかったクライアントを拒否し(エラー -32021)、ゲートを省いたサーバーから届いた引き受け対象の形状は検証に失敗します。認識できない `resultType` に対して仕様が求めるとおりです。通信路の両端で、デフォルトはオフです。 @@ -124,15 +137,15 @@ TypeError: Stamps.identifier must be a `vendor-prefix/name` string ```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} `ClientExtension` をサブクラス化し、必要なものだけをオーバーライドします。提供できるものは 3 種類で、それぞれにデフォルトがあります。`settings()`、`claims()`、`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" ``` * 識別子はサーバー側と同じ文法に従い、クラスの定義時に検証されます。 @@ -153,12 +166,18 @@ def notifications(self) -> Sequence[NotificationBinding[Any]]: ### 拡張機能の動詞 {#extension-verbs} -拡張機能独自のリクエストメソッドには、クライアント側の登録は不要です。ベンダーリクエスト型は `mcp.types.Request` をサブクラス化し、[独自メソッドの提供](#serving-your-own-methods)と同様に `client.session.send_request` を通ります。追加が 1 つあります。パラメーターのキーを `Mcp-Name` ヘッダーに載せなければならない場合(tasks のような拡張機能の仕様では、その動詞にこれが必要です)、リクエスト型は `name_param` を宣言します。 +拡張機能独自のリクエストメソッドには、クライアント側の登録は不要です。ベンダーリクエスト型は `mcp.types.Request` をサブクラス化し、[独自メソッドの提供](#serving-your-own-methods)と同様に `client.session.send_request` を通ります。名前付きのジョブに関する動詞を 1 つ、拡張機能が配信するサーバーを例に取りましょう。 -```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" ``` +クライアント側での追加が 1 つあります。パラメーターのキーを `Mcp-Name` ヘッダーに載せなければならない場合(tasks のような拡張機能の仕様では、その動詞にこれが必要です)、リクエスト型は `name_param` を宣言します。 + +```python title="client.py" hl_lines="20-23 28-29" +--8<-- "docs_src/extensions/tutorial007_client.py" +``` + セッションはどの送信経路でも `params["jobId"]` を `Mcp-Name` に反映し、値が欠けている場合は必須ヘッダーを黙って省くのではなく、はっきりとエラーになります。 ## 拡張機能にできないこと {#what-an-extension-cannot-do} diff --git a/i18n/ja/pages/advanced/low-level-server.md b/i18n/ja/pages/advanced/low-level-server.md index c940ae82..6681b622 100644 --- a/i18n/ja/pages/advanced/low-level-server.md +++ b/i18n/ja/pages/advanced/low-level-server.md @@ -1,6 +1,6 @@ --- translation: - sections: [2c79b6338e09b7ac, 7edc43b3fae11314, 1086e77ce561cd7f, a3f71823df5efc31, 9fc7109f72201cae, d50fe7faead8cf68, 7bf25983df655b66, 6330e1f4c6029683, 2f1749c8c133fa1c, 8db7116fc8ddd0ee, ebc33704fbd74262, cd0e9c933350390e] + sections: [2c79b6338e09b7ac, 9d5d10a5f0405d0a, 1086e77ce561cd7f, a3f71823df5efc31, 9fc7109f72201cae, d50fe7faead8cf68, 7bf25983df655b66, 6330e1f4c6029683, 2f1749c8c133fa1c, 8db7116fc8ddd0ee, ebc33704fbd74262, 0fde3bcea081ba3a] tool: 1 --- # 低レベルの Server {#the-low-level-server} @@ -36,18 +36,22 @@ translation: ### 試してみる {#try-it} -これには Inspector がありません。`mcp dev` と `mcp run` は `MCPServer` しか受け付けないからです。インメモリの `Client` は気にしません。`MCPServer` を受け取るのとまったく同じように、低レベルの `Server` を受け取ります。 +`mcp dev` と `mcp run` は `MCPServer` しか受け付けないので、このサーバーは自分で起動します。`server.py` の最後の行がここから普通の ASGI アプリを組み立て、uvicorn がそれを実行します。 -```python title="main.py" +```console +uvicorn server:app --port 8000 +``` + +Inspector でも、どのクライアントでも、`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) @@ -64,6 +68,8 @@ asyncio.run(main()) * `result.structured_content` は `None` です。高レベルのサーバーは `-> str` を `{"result": ...}` にラップしてくれますが、ここでは自分で組み立てなかったものを誰も組み立ててくれません。 * `list_tools` は**自分で**打ち込んだスキーマを一字一句そのまま返します。高レベル版にはすべてのプロパティに `"title": "Query"` があり、ルートに `"title": "search_booksArguments"` がありました。Pydantic の産物です。この層では、通信上に現れるものはすべて自分が載せたものです。 +テストでは uvicorn もポートも省けます。`Client(server)` は `MCPServer` を受け取るのとまったく同じように、低レベルの `Server` をインプロセスで受け取ります。**[テスト](../get-started/testing.md)** がまさにそのパターンです。 + ## 何もチェックされない {#nothing-is-checked-for-you} `MCPServer` は、生成したスキーマに照らして呼び出しを検証し、関数が実行される前に不正な引数を拒否します(**[ツール](../servers/tools.md)**)。 @@ -214,4 +220,4 @@ use Server.middleware to observe or wrap initialization * `add_request_handler(method, params_type, handler)` は任意のメソッドを提供します。`initialize` は予約されています。 * `Server` が公開するケイパビリティは、どのハンドラーを登録したかから導出されます。 -`Client(server)` が両方のサーバーを同じように扱ったのは、両者がまさに同じプロトコルだからであり、それこそが要点です。さらに下の層はクラスですらありません。**[ミドルウェア](middleware.md)** です。 +クライアントが両方のサーバーを同じように扱ったのは、両者がまさに同じプロトコルだからであり、それこそが要点です。さらに下の層はクラスですらありません。**[ミドルウェア](middleware.md)** です。 diff --git a/i18n/ja/pages/advanced/pagination.md b/i18n/ja/pages/advanced/pagination.md index fb8c4890..6a2a0033 100644 --- a/i18n/ja/pages/advanced/pagination.md +++ b/i18n/ja/pages/advanced/pagination.md @@ -1,6 +1,6 @@ --- translation: - sections: [a9aba7a026c7bd85, ed32bda7ba9ae33a, 7e64cc5646abb91f, 22a0129ee78b3c63, d875373c06d8d2f9] + sections: [a9aba7a026c7bd85, 83e2a08b9d46a398, 9fd8a0aa384b3257, 22a0129ee78b3c63, d875373c06d8d2f9] tool: 1 --- # ページネーション {#pagination} @@ -29,9 +29,13 @@ translation: ### 試してみる {#try-it} -`Client(server)` は、`MCPServer` に接続するのとまったく同じように、低レベルの `Server` にメモリ内で接続します。 +`mcp run` は `MCPServer` しか受け付けないので、このサーバーは自分で配信します。`server.py` の最後の行で `Server` から普通の ASGI アプリを組み立てており、それを uvicorn が実行します。 -引数なしで `list_resources()` を呼び出してください。`book-1` から `book-10` までの 10 個のリソースが返り、`next_cursor` は文字列 `"10"` です。 +```console +uvicorn server:app --port 8000 +``` + +任意のクライアント(**[クライアント](../client/index.md)**、または Inspector)を `http://localhost:8000/mcp` に向け、引数なしで `list_resources()` を呼び出してください。`book-1` から `book-10` までの 10 個のリソースが返り、`next_cursor` は文字列 `"10"` です。 それを `list_resources(cursor="10")` として返すと、最初のリソースは `book-11` になり、新しい `next_cursor` は `"20"` です。 @@ -41,7 +45,7 @@ translation: `Client` のすべての `list_*` メソッド(`list_tools`、`list_resources`、`list_resource_templates`、`list_prompts`)は `cursor=` キーワードを受け取ります。ページングされた一覧をすべて取り出すには、`while True` を 1 つ書くだけです。 -```python title="client.py" hl_lines="26-32" +```python title="client.py" hl_lines="9-15" --8<-- "docs_src/pagination/tutorial002.py" ``` @@ -49,7 +53,7 @@ translation: * `next_cursor` を見る**前に** extend してください。最後のページにもリソースはあります。 * `next_cursor is None` が出口です。それ以外はそのまま、手を加えずに `cursor=` に戻します。 -その `main()` を実行すると `100 resources` と表示されます。10 件ずつの 10 ページが、10 ページあることなど知らないループによってつなぎ合わされた結果です。 +uvicorn が `server.py` を配信したままの状態で、2 つ目のターミナルで `python client.py` を実行してください。`100 resources` と表示されます。10 件ずつの 10 ページが、10 ページあることなど知らないループによってつなぎ合わされた結果です。 これは **[クライアント](../client/index.md)** がすべての `list_*` メソッドについて示しているのと同じループで、ページングしないサーバーに対してもコストはかかりません。最初のレスポンスで `next_cursor` が `None` になり、ループは 1 回だけ回ります。 diff --git a/i18n/ja/pages/client/caching.md b/i18n/ja/pages/client/caching.md index 79ffd119..dc9914a6 100644 --- a/i18n/ja/pages/client/caching.md +++ b/i18n/ja/pages/client/caching.md @@ -1,6 +1,6 @@ --- translation: - sections: [9e7b9a1710e5aeba, b74ca4c1d2ddddee, fa8714e61bf90c5a, 04db67a886b7271c, 857690fb8f876800] + sections: [9e7b9a1710e5aeba, 66a2e9acc9101d54, d3d25aa802f5145a, 04db67a886b7271c, 857690fb8f876800] tool: 1 --- # キャッシュヒント {#caching-hints} @@ -27,7 +27,7 @@ translation: 低レベルの `Server` では、ハンドラーが結果を手作業で組み立てます。`ttl_ms` と `cache_scope` は結果モデルの単なるフィールドです。これらを明示的に設定したハンドラーは、フィールド単位で常にコンストラクターのマップより優先されます。 -```python title="server.py" hl_lines="10 16" +```python title="server.py" hl_lines="11 17" --8<-- "docs_src/caching/tutorial002.py" ``` @@ -41,10 +41,24 @@ translation: 2026-07-28 のセッションでは、`Client` がヒントに自動で従います。組み込みのレスポンスキャッシュがあり、デフォルトで有効です。`ttlMs` を持って届いた結果は保存され、その TTL 内に同一の呼び出しがあれば、ラウンドトリップなしでキャッシュから返されます。ヒントを「持たない」結果はキャッシュされません。ヒントのない結果には `CacheConfig.default_ttl_ms` が適用され、そのデフォルトは `0`(すぐに古くなる)です。そのため、何も宣言しないサーバーには、これまでとまったく同じ呼び出しごとのトラフィックが届きます。 -```python title="client.py" hl_lines="33 35 38" +これが起こる様子を見るには、前のセクションの `server.py` を uvicorn で起動してください(最後の行が ASGI アプリを組み立てています)。ハンドラーは実際に実行されるたびに 1 行出力します。 + +```console +uvicorn server:app --port 8000 +``` + +```python title="client.py" hl_lines="20 23 28" --8<-- "docs_src/caching/tutorial003.py" ``` +2 つ目のターミナルから `python client.py` を実行してください。最初の結果が持っていたヒント、つまりハンドラーの `ttlMs` とマップの `cacheScope` が並んで出力されます。 + +```text +1000 public +``` + +残りはサーバー側のターミナルを見ればわかります。uvicorn のリクエストログの合間に、`tools/list served` が 3 回現れます。 + 呼び出し 4 回、取得 3 回です。2 回目の呼び出しは新鮮なエントリーを見つけ、サーバーに到達しませんでした。(注入した)クロックを TTL の先へ進めたことで、3 回目は再び取得しました。4 回目は `cache_mode="refresh"` を指定しています。このキーワード引数はキャッシュ対象の 5 つのメソッド(`list_tools`、`list_prompts`、`list_resources`、`list_resource_templates`、`read_resource`)にあります。 * `"use"`(デフォルト)は、新鮮なエントリーがあればそれを返し、なければ取得して保存します。 @@ -53,7 +67,7 @@ translation: `"use"` より上位にルールが 1 つあります。**`meta` を持つ呼び出しは必ずサーバーに到達します。**`meta` を設定したリクエスト(進捗トークンやトレーシング用フィールドなど)は実際のリクエスト送信を前提にしているため、`cache_mode="use"` では `"refresh"` として扱われます。キャッシュの読み込みは省略され、取得した結果は引き続きキャッシュのエントリーを置き換えます。`"bypass"` と明示的な `"refresh"` はいつもどおりに動作します。 -キャッシュを完全に無効にするには、`Client(server, cache=None)` で構築してください。すべての呼び出しが再びラウンドトリップになり、`cache_mode` は受け付けられるものの何もしません。 +キャッシュを完全に無効にするには、`Client` の構築時に `cache=None` を渡してください。すべての呼び出しが再びラウンドトリップになり、`cache_mode` は受け付けられるものの何もしません。 スコープも自動的に尊重されます。`"private"` のエントリーはキャッシュの「パーティション」(後述)をキーにし、`"public"` のエントリーはより広い共有を選べます。そして、名指しされたエントリーについては**通知が TTL に勝ちます**。`list_changed` 通知は対応するキャッシュ済みの一覧を破棄し、`resources/updated` はその URI と完全に一致するキーで保存されたキャッシュ済みの読み込み結果を、どれだけ新鮮でも破棄します。2026-07-28 の接続では、これらの通知は `client.listen(...)` で開く `subscriptions/listen` ストリームに届き、破棄はウォッチャーがイベントを見る前に完了します。詳しくは **[サブスクリプション](subscriptions.md)** を参照してください。 diff --git a/i18n/ja/pages/client/callbacks.md b/i18n/ja/pages/client/callbacks.md index bcf4c266..2ad71759 100644 --- a/i18n/ja/pages/client/callbacks.md +++ b/i18n/ja/pages/client/callbacks.md @@ -1,6 +1,6 @@ --- translation: - sections: [adf3c545b5be46b6, 916cd3ab1c03f461, e9be7a8d0eb0a456, 565890a636288ecf, 6af7e49db9129ec3, 06b0238c174186af, 90c6043be435fcb0] + sections: [adf3c545b5be46b6, 916cd3ab1c03f461, 32ef568335dd95a7, 565890a636288ecf, 6af7e49db9129ec3, 06b0238c174186af, 0abc5ea5cb7ff6b3] tool: 1 --- # クライアントのコールバック {#client-callbacks} @@ -58,7 +58,7 @@ result.content # [TextContent(type='text', text='Card issued to Ada Lovelace.') こちらから `tools/call` が 1 回、サーバーからの折り返しの `elicitation/create` が 1 回、それに答えるのがこちらの関数です。すべてが 1 回のツール呼び出しの中で完結します。 !!! info - `Client(...)` の呼び出しにある `mode="legacy"` は、実際に働いています。デフォルトでは `Client(...)` は新しいプロトコルの経路をネゴシエートしますが、その経路にはサーバーからクライアントへのリクエストのためのバックチャネル(back-channel)がありません。コールバックが動く前に `ctx.elicit` が失敗します。これを決めるのはトランスポートではなく、ネゴシエートされたプロトコルです。インメモリでも URL 越しでも同じです。クライアントがこうしたリクエストに答える必要があるときは、必ず `mode="legacy"` を指定してください。このページの裏にあるテストはすべてそうしています。詳しくは **[プロトコルバージョン](../protocol-versions.md)** を参照してください。 + `Client(...)` の呼び出しにある `mode="legacy"` は、実際に働いています。デフォルトでは `Client(...)` は新しいプロトコルの経路をネゴシエートしますが、その経路にはサーバーからクライアントへのリクエストのためのバックチャネル(back-channel)がありません。コールバックが動く前に `ctx.elicit` が失敗します。これを決めるのはトランスポートではなく、ネゴシエートされたプロトコルです。クライアントがこうしたリクエストに答える必要があるときは、必ず `mode="legacy"` を指定してください。このページの裏にあるテストはすべてそうしています。詳しくは **[プロトコルバージョン](../protocol-versions.md)** を参照してください。 2026-07-28 のセッションでもコールバックが使われなくなるわけではなく、呼ばれ方が変わります。ツールが `ElicitRequest` を含む `InputRequiredResult` を返すと、`Client` はそのエントリを同じ `elicitation_callback` に振り分け、呼び出しを再試行してくれます。この流れは **[マルチラウンドトリップ(multi-round-trip)リクエスト](../handlers/multi-round-trip.md)** で説明しています。 @@ -139,4 +139,4 @@ result.structured_content # {'result': ['elicitation']} * `sampling_callback` と `list_roots_callback` も同じように動きますが、非推奨の機能のためのものです。新しいサーバーは代わりにマルチラウンドトリップリクエストを使います。 * `logging_callback` と `message_handler` は通知を受け取ります。何も宣言しません。 -`Client(...)` の第 1 引数はトランスポートのオブジェクトです。すべての種類は **[クライアントのトランスポート](transports.md)** で扱っています。 +`Client(...)` の第 1 引数でトランスポートが決まります。すべての種類は **[クライアントのトランスポート](transports.md)** で扱っています。 diff --git a/i18n/ja/pages/client/index.md b/i18n/ja/pages/client/index.md index 7ca42416..7e53cc20 100644 --- a/i18n/ja/pages/client/index.md +++ b/i18n/ja/pages/client/index.md @@ -1,6 +1,6 @@ --- translation: - sections: [ebef1e7a0df854f4, 8355cfaf1f76c9d5, 8e79141fc2985342, 46bdb07c7537e8a5, 80ce41579825a6fa, 5f0fa90494de8f65, 83d10514eaa62fa5, 9190555aa39a5d28, 84a4c9d8bf14dddb, 927d71cf40b58c30] + sections: [ebef1e7a0df854f4, 7e16449f66e7dfd6, eeb0682f7d2a1079, 5713f0196a34e6e7, 0e844597859e4248, 3a97d9195ddcc92e, 1da08c483e59c141, 84702cc6e0a1fd42, 8dee7a31c86ffc5c, 83a5bce168ef23d7] tool: 1 --- # Client {#the-client} @@ -11,13 +11,23 @@ translation: ## 最初のクライアント {#your-first-client} -```python title="client.py" hl_lines="14-18" +クライアントには、対話する相手のサーバーが必要です。このページのすべてのスニペットが接続するのが、この Bookshop です。`server.py` として保存し、HTTP で起動したままにしてください。 + +```python title="server.py" --8<-- "docs_src/client/tutorial001.py" ``` -冒頭のサーバーは、接続先を用意するためだけにあります。クライアントはハイライトされた 5 行です。 +```console +uv run mcp run server.py --transport streamable-http +``` -* `Client(mcp)` には**サーバーオブジェクトそのもの**を渡しています。これがインメモリのトランスポートです。サブプロセスもポートも HTTP もありません。このページのすべての例、そして作成するすべてのテストが、この方法で接続します。 +これでサーバーは `http://localhost:8000/mcp` で提供されます。クライアントは別のプログラムです。`client.py` として保存し、2 つ目のターミナルで `python client.py` を実行してください。 + +```python title="client.py" hl_lines="7-11" +--8<-- "docs_src/client/tutorial001_client.py" +``` + +* `Client("http://localhost:8000/mcp")` には **URL** を渡しているので、いま起動したサーバーに Streamable HTTP で接続します。 * `async with` が**ライフサイクル**です。入ると接続してネゴシエーションを行い、出ると切断します。`connect()` / `close()` のペアはなく、ブロックが終わった後の `Client` は再利用できません。 * ブロックの中では、接続に関する情報がすでに通常のプロパティとして揃っています。 @@ -25,10 +35,10 @@ translation: `Client` は位置引数を 1 つ取り、その型からトランスポートを決定します。 -* `MCPServer`(または低レベルの `Server`)のインスタンス:**プロセス内**で接続します。 -* URL 文字列(`Client("http://localhost:8000/mcp")`):Streamable HTTP。本番向けの経路です。 -* `StdioServerParameters`:**サブプロセス**として起動するコマンドで、その stdin と stdout を通じて対話します。 +* URL 文字列(`Client("http://localhost:8000/mcp")`):Streamable HTTP。デプロイで使うトランスポートです。 +* `StdioServerParameters`:ローカルの**サブプロセス**として起動するコマンドで、その stdin と stdout を通じて対話します。 * **トランスポート**:`async with ... as (read, write)` できるものなら何でも。たとえば、自分の HTTP クライアントをラップする `streamable_http_client(url, http_client=...)` です。 +* `MCPServer`(または低レベルの `Server`)のインスタンス:**プロセス内**で接続します。サブプロセスもポートもありません。これはテスト向けで、**[テスト](../get-started/testing.md)** がこれを土台にしています。 このページの残りの内容は、4 つのどれでも同じです。ヘッダー、サブプロセス、タイムアウト、そして `Transport` プロトコルについては、専用のページ **[クライアントのトランスポート](transports.md)** があります。 @@ -48,11 +58,11 @@ translation: ## ツールの一覧取得 {#listing-tools} -```python title="client.py" hl_lines="15-20" +```python title="client.py" hl_lines="8-13" --8<-- "docs_src/client/tutorial002.py" ``` -`list_tools()` は `ListToolsResult` を返し、ツールは `.tools` に入っています。それぞれが、ホストがモデルに渡す完全な定義です。 +`list_tools()` は `ListToolsResult` を返し、ツールは `.tools` に入っています。それぞれが、ホストがモデルに渡す完全な定義です。最初のツールは次のとおりです。 ```python tool.name # 'search_books' @@ -76,6 +86,8 @@ tool.description # 'Search the catalog by title or author.' このスキーマには、UI が引数フォームを描画するのに必要なものも、モデルが有効な引数を生成するのに必要なものも、すべて含まれています。 +2 つ目のツール `lookup_book` は `title=` なしで登録されているので、その `tool.title` は `None` です。 + !!! tip `title` は省略可能なので、人間にツールを見せる UI はどちらかを選ぶ必要があります。`title` があればそれを、なければ `name` を使います。`from mcp.shared.metadata_utils import get_display_name` がまさにそれを行い、ツール、リソース、リソーステンプレート、プロンプトに対応しています。 @@ -83,7 +95,7 @@ tool.description # 'Search the catalog by title or author.' `call_tool(name, arguments)` はツールを実行し、`CallToolResult` を返します。 -```python title="client.py" hl_lines="27-34" +```python title="client.py" hl_lines="9-16" --8<-- "docs_src/client/tutorial003.py" ``` @@ -131,7 +143,7 @@ result.is_error # False リソースの動詞は組になっています。一覧取得が 2 通り、読み取りが 1 通りです。 -```python title="client.py" hl_lines="22-31" +```python title="client.py" hl_lines="9-18" --8<-- "docs_src/client/tutorial004.py" ``` @@ -141,11 +153,11 @@ result.is_error # False `read_resource` は `contents` を返します。これは `TextResourceContents` または `BlobResourceContents` のリストです。考え方はツールのコンテンツと同じで、`isinstance` で絞り込んでから `.text`(または `.blob`)を読みます。 -クライアントは、リソースが変更されたときに通知を受けることもできます。2025 年世代の接続では `subscribe_resource(uri)` / `unsubscribe_resource(uri)` がそれにあたります。ただしこのメソッドのペアは `MCPServer` が実装していないため、2026-07-28 の通信上(これらの動詞はもう存在しません)ではリクエストに `-32601`、*Method not found* が返ります。2026 年の代替は `subscriptions/listen` ストリームで、こちらは `MCPServer` が実際に提供しています(そこでは `server_capabilities.resources.subscribe` が `True` です)。これを `client.listen(...)` で消費する方法は、このセクションの **[サブスクリプション](subscriptions.md)** のページで説明しています。 +クライアントは、リソースが変更されたときに通知を受けることもできます。2025 年世代の接続では `subscribe_resource(uri)` / `unsubscribe_resource(uri)` がそれにあたります。このメソッドのペアは `MCPServer` が実装していないため、2026-07-28 の通信上(これらの動詞はもう存在しません)ではリクエストに `-32601`、*Method not found* が返ります。2026 年の代替は `subscriptions/listen` ストリームで、こちらは `MCPServer` が実際に提供しています(そこでは `server_capabilities.resources.subscribe` が `True` です)。これを `client.listen(...)` で消費する方法は、このセクションの **[サブスクリプション](subscriptions.md)** のページで説明しています。 ## プロンプト {#prompts} -```python title="client.py" hl_lines="15-20" +```python title="client.py" hl_lines="8-13" --8<-- "docs_src/client/tutorial005.py" ``` @@ -170,7 +182,7 @@ message.content # TextContent(type='text', text='Recommend one poetry book from 補完ハンドラーを持つサーバーは、ユーザーの入力に合わせてプロンプトやリソーステンプレートの引数を自動補完できます。 -```python title="client.py" hl_lines="27-31" +```python title="client.py" hl_lines="9-13" --8<-- "docs_src/client/tutorial006.py" ``` @@ -183,21 +195,21 @@ message.content # TextContent(type='text', text='Recommend one poetry book from `list_*` メソッドはどれも `cursor=` キーワードを取り、結果はどれも `next_cursor` を持ちます。`next_cursor` が `None` なら、すべて取得済みです。 -```python title="client.py" hl_lines="22-30" +```python title="client.py" hl_lines="7-15" --8<-- "docs_src/client/tutorial007.py" ``` -このループはどのサーバーに対しても正しく動きます。`MCPServer` はすべてを 1 ページで返すので、`next_cursor` は `None` になり、ループは 1 回だけ実行されます。ほとんどのコードがこのループを書かないのはそのためです。実際にページ分割するサーバーと、カーソルが従うルールについては **[ページネーション](../advanced/pagination.md)** を参照してください。 +`list_all_tools` はどのサーバーに対しても正しく動きます。`MCPServer` はすべてを 1 ページで返すので、`next_cursor` は `None` になり、ループは 1 回だけ実行されます。ほとんどのコードがこのループを書かないのはそのためです。実際にページ分割するサーバーと、カーソルが従うルールについては **[ページネーション](../advanced/pagination.md)** を参照してください。 ## テストでの利用 {#in-tests} -プロセスもポートも使わない `Client(mcp)` は、それだけでサーバーのテストハーネスになります。 +このページの `client.py` はどれも、HTTP 経由で `server.py` に到達していました。テストではネットワークを省き、`Client` にサーバーオブジェクトそのものを渡します。`from server import mcp` としてから `Client(mcp)` です。プロセスもポートも不要で、上記のメソッドはどれも同じように動きます。 -そのために用意されたコンストラクターのフラグが 1 つあります。`Client(mcp, raise_exceptions=True)` です。効果があるのはインメモリ接続のときだけで、その説明と、それを中心にしたパターン全体の組み立ては **[テスト](../get-started/testing.md)** のページにあります。 +そのために用意されたコンストラクターのフラグが 1 つあります。`Client(mcp, raise_exceptions=True)` です。効果があるのはプロセス内接続のときだけで、その説明と、それを中心にしたパターン全体の組み立ては **[テスト](../get-started/testing.md)** のページにあります。 ## まとめ {#recap} -* `Client(x)` は、サーバーオブジェクトにはインメモリで、URL 文字列には Streamable HTTP で、それ以外にはトランスポート経由で接続します。 +* `Client(x)` は、URL 文字列には Streamable HTTP で接続し、`StdioServerParameters` にはサブプロセスを起動し、トランスポートには直接入り、テストではサーバーオブジェクトそのものを受け取ります。 * `async with` がライフサイクルのすべてです。その中では `server_capabilities` と `protocol_version` にすでに値が入っており、サーバーが提供していれば `server_info` と `instructions` も同様です。 * `list_tools()` で各ツールの `name`、`title`、`description`、`input_schema` が得られます。 * `call_tool()` はモデル向けの `content`、コード向けの `structured_content`、そして `is_error` を返します。例外を送出するツールは、例外ではなく結果として返ってきます。 diff --git a/i18n/ja/pages/client/oauth-clients.md b/i18n/ja/pages/client/oauth-clients.md index e71580ae..fe05023d 100644 --- a/i18n/ja/pages/client/oauth-clients.md +++ b/i18n/ja/pages/client/oauth-clients.md @@ -1,6 +1,6 @@ --- translation: - sections: [c6899d3892bd9fa0, 79372cff3cc48a88, 63878d29e87c3e73, 13175843d3588af4, e7e2b9fd516f77de, 758f06399b513c1f, a05d7278487d610b] + sections: [c6899d3892bd9fa0, 79372cff3cc48a88, c2dae1ebe2ebd543, 13175843d3588af4, df06056fb16b3846, 758f06399b513c1f, a05d7278487d610b] tool: 1 --- # OAuth クライアント {#oauth-clients} @@ -77,18 +77,20 @@ translation: `Client` が初めてリクエストを送ると、サーバーは `401` を返します。そこからプロバイダーが引き継ぎます。 -1. **発見。** `WWW-Authenticate` ヘッダーを読み、サーバーの Protected Resource Metadata を `/.well-known/oauth-protected-resource` から取得します。そこからこのリソースを保護している認可サーバーを知り、「その」サーバーのメタデータを取得します。 +1. **発見。** `WWW-Authenticate` ヘッダーを読み、サーバーの Protected Resource Metadata を `/.well-known/oauth-protected-resource` から取得します。そこからこのリソースを保護している認可サーバーを知り、「その」サーバーのメタデータを取得します。(リソースメタデータを公開していない古いサーバーには、代わりにそのサーバー自身のオリジンで認可サーバーのメタデータを問い合わせます。)どちらの場合も、メタデータは取得対象のサーバーを `issuer` として名指ししていなければなりません。それ以外は拒否されます。 2. **登録。** ストレージに何もなければ、`OAuthClientMetadata` を使って動的に登録し、結果を保存します。 3. **認可。** PKCE のペアと `state` を生成し、認可 URL を組み立て、`redirect_handler` を await します。続いて、コードを受け取るために `callback_handler` を await します。 4. **交換。** コードを `OAuthToken` と引き換えて保存し、元のリクエストを `Authorization: Bearer ...` 付きで再送します。 それ以降は静かになります。トークンはストレージから取り出され、期限切れのアクセストークンはリフレッシュトークンで更新されます。そのどれもうまくいかないときだけ、フローをもう一度実行します。 +これらのリクエストすべてに共通するトランスポートのルールが 1 つあります。外側の MCP リクエストと同じく、リダイレクトに従うのは同じオリジンにとどまり、かつメソッドを保つ場合(たとえば末尾スラッシュの 307/308)だけです。それ以外のリダイレクトは、その URL が応答しなかったものとして扱います。 + これらを自分で書く必要はまったくありませんでした。残るキーワード引数は 2 つ(`client_metadata_url` と `validate_resource_url`)で、このファイルではどちらも不要です。知っておく価値があるのは `client_metadata_url` のほうで、下に専用のセクションがあります。 ### 試してみる {#try-it} -このドキュメントの例のほとんどは、インメモリの `Client(server)` で確認できます。これは違います。このフローの要点は HTTP の `401` であり、インメモリのクライアントとサーバーのあいだには HTTP がありません。 +テストで使うインメモリの `Client(server)` は、ここでは役に立ちません。このフローの要点は HTTP の `401` であり、インメモリのクライアントとそのサーバーのあいだには HTTP がないからです。 リポジトリには実際に動くバージョンが同梱されています。`examples/servers/simple-auth/` はスタンドアロンの認可サーバーと保護された MCP サーバーを動かし、`examples/clients/simple-auth-client/` はこのページのクライアントを小さな CLI に育てたものです。その README に 2 つのコマンドが載っています。サーバーを起動し、それに対してクライアントを実行すれば、4 つのステップが進んでいくのを見られます。 @@ -106,13 +108,14 @@ URL は HTTPS で、ルート以外のパスを持っている必要がありま `ClientCredentialsOAuthProvider` は同じ `httpx2.Auth` で、人間がいないだけです。 -```python title="client.py" hl_lines="4 27-33" +```python title="client.py" hl_lines="4 27-34" --8<-- "docs_src/oauth_clients/tutorial002.py" ``` 変わった点は次のとおりです。 * `OAuthClientMetadata` もハンドラーもありません。`client_id` と `client_secret` を渡すと、プロバイダーはそれらを中心に最小限の `client_credentials` 登録を組み立て、動的登録を完全に省きます。 +* `issuer` には、そのクレデンシャルを発行した認可サーバーを指定します。そのサーバーの `/.well-known/oauth-authorization-server` ドキュメントが返す `issuer` の値を使ってください。発見は上と同じように実行されますが、トークンリクエストは「その」発行者のメタデータからしか組み立てられません。MCP サーバーがそれ以外の場所を指していれば、フローは代わりに `OAuthFlowError` で止まります。省略するのは非推奨で、3.0 では必須になります(**[非推奨の機能](../deprecated.md#deprecated-sdk-helpers)** を参照してください)。それまでのあいだ、プロバイダーは警告を出したうえで、発見で見つかった認可サーバーをそのまま使います。 * `scope` はスペース区切りの文字列で、OAuth の通信上の形式です。 * その先はすべて同じです。同じ `TokenStorage`、同じ `httpx2.AsyncClient(auth=...)`、同じ `streamable_http_client` です。 @@ -123,7 +126,7 @@ URL は HTTPS で、ルート以外のパスを持っている必要がありま !!! info `mcp.client.auth.extensions.client_credentials` にはもう 1 つプロバイダーがあります。 - **`PrivateKeyJWTOAuthProvider`** は、共有シークレットの代わりに JWT で認証するクライアント向けです(`private_key_jwt`、つまり鍵ペアやワークロードアイデンティティの方式)。パターンは同じで、1 つ構築して `auth=` に載せます。同じモジュールには、そのアサーションを組み立てる 2 つのヘルパー、`SignedJWTParameters` と `static_assertion_provider` も含まれています。 + **`PrivateKeyJWTOAuthProvider`** は、共有シークレットの代わりに JWT で認証するクライアント向けです(`private_key_jwt`、つまり鍵ペアやワークロードアイデンティティの方式)。パターンは同じで、1 つ構築して(同じく省略可能な `issuer` を取ります)`auth=` に載せます。同じモジュールには、そのアサーションを組み立てる 2 つのヘルパー、`SignedJWTParameters` と `static_assertion_provider` も含まれています。 人間がいない状況はもう 1 つあります。クライアントが企業に属していて、どの MCP サーバーに到達してよいかをユーザーではなくその企業のアイデンティティプロバイダーが決める場合です。これは独自の信頼モデルを持つ別のグラントで、専用のページ **[アイデンティティアサーション](identity-assertion.md)** があります。 diff --git a/i18n/ja/pages/client/session-groups.md b/i18n/ja/pages/client/session-groups.md index 88c62acb..af1c4767 100644 --- a/i18n/ja/pages/client/session-groups.md +++ b/i18n/ja/pages/client/session-groups.md @@ -1,6 +1,6 @@ --- translation: - sections: [09c857a25a9dc37a, 43bc6a76a243a50e, 0a716022a88768df, 4b7f78042bfcfff7, c112662e61b03315, 58974ba1f489a8b4, d18adbdbb835ea73] + sections: [09c857a25a9dc37a, 43bc6a76a243a50e, 0a716022a88768df, 4b7f78042bfcfff7, c112662e61b03315, 58974ba1f489a8b4, ed4d17e894864056] tool: 1 --- # セッショングループ {#session-groups} @@ -76,7 +76,7 @@ translation: ## まとめ {#recap} * `ClientSessionGroup` は多数のサーバー接続を保持し、それらのツール、リソース、プロンプトをそれぞれ 1 つの `dict` にまとめます。 -* サーバーごとに `connect_to_server(params)` を呼びます。受け取るのはトランスポートのパラメーターであり、`Client` が受け取るサーバーオブジェクトや URL ではありません。 +* サーバーごとに `connect_to_server(params)` を呼びます。受け取るのはトランスポートのパラメーターであり、`Client` が受け取る URL や `Transport` ではありません。 * `group.call_tool(name, arguments)` は、所有するサーバーへのルーティングを代わりに行います。 * 名前はグループ全体で一意でなければなりません。`search` ツールを持つ 2 つのサーバーは、そのままでは共存できません。 * `component_name_hook=` は登録されるすべての名前を書き換えます。dict のキーは変わりますが、実際に送信される名前は変わりません。 diff --git a/i18n/ja/pages/client/transports.md b/i18n/ja/pages/client/transports.md index 4c61766a..409d4916 100644 --- a/i18n/ja/pages/client/transports.md +++ b/i18n/ja/pages/client/transports.md @@ -1,6 +1,6 @@ --- translation: - sections: [9cac816674181eb0, 0700f337babcd4dd, 2bde0dd58cdf00f5, 40b4916d82eaf1d4, 3d0832f39b0d7059, dfa4446556badef0, 5bd93be2ab2ecb9c] + sections: [9cac816674181eb0, 7c157764133fea1f, 40b4916d82eaf1d4, 10d151f2cc75317f, 3d0832f39b0d7059, 92742ba36533633d, 0aeca6145e7bd302] tool: 1 --- # クライアントのトランスポート {#client-transports} @@ -11,30 +11,15 @@ translation: それぞれの「サーバー」側(`mcp.run()` が何をするのか、何をデプロイするのか)については、**[サーバーの実行](../run/index.md)** を参照してください。 -## インメモリ {#in-memory} - -サーバーオブジェクトそのものを渡します。 - -```python title="client.py" hl_lines="14" ---8<-- "docs_src/client_transports/tutorial001.py" -``` - -サブプロセスも、ポートも、通信路を流れるバイト列もありません。クライアントとサーバーは同じプロセス内の 2 つのオブジェクトですが、呼び出しは本物のプロトコル層を通ります。`search_books` は、HTTP 越しの場合とまったく同じように一覧に載り、検証され、呼び出されます。 - -そのため、これは同時に 2 つの役割を果たします。 - -* **テストハーネス。** このドキュメントの例はすべてこの方法で実行されており、**[テスト](../get-started/testing.md)** のページはこのパターンを中心に組み立てられています。 -* **組み込み用の API。** サーバーを自分で構築するアプリケーションなら、そのツールを呼び出すのにネットワーク越しの経路は必要ありません。 - ## Streamable HTTP {#streamable-http} -URL の文字列を渡すと **Streamable HTTP** になります。デプロイ時に使うトランスポートです。 +URL の文字列を渡すと **Streamable HTTP** になります。デプロイ時に使うトランスポートであり、最初に選ぶべきトランスポートです。 ```python title="client.py" hl_lines="5" --8<-- "docs_src/client_transports/tutorial002.py" ``` -本番用のクライアントはこれですべてです。`Client` は URL を `streamable_http_client(...)` で包み、MCP に必要な設定を施した `httpx2.AsyncClient` の上に載せてくれます。具体的には `follow_redirects=True`、connect/write/pool のタイムアウトが 30 秒、そしてサーバーがレスポンスストリームを開いたままにすることがあるため read のタイムアウトが 300 秒です。 +本番用のクライアントはこれですべてです。`Client` は URL を `streamable_http_client(...)` で包み、MCP に必要な設定を施した `httpx2.AsyncClient` の上に載せてくれます。具体的には、connect/write/pool のタイムアウトが 30 秒、そしてサーバーがレスポンスストリームを開いたままにすることがあるため read のタイムアウトが 300 秒です。 !!! check 構築しただけの `Client` は接続されて**いません**。構築時に行われるのはトランスポートの選択だけで、実際に開くのは `async with` です。入る前に接続に手を伸ばすと、SDK がそのことを教えてくれます。 @@ -49,7 +34,7 @@ URL の文字列を渡すと **Streamable HTTP** になります。デプロイ `Authorization` ヘッダー、Cookie、プロキシ、mTLS、あるいは別のタイムアウトが必要になったら、`httpx2.AsyncClient` を自分で組み立てて `streamable_http_client` に渡します。 -```python title="client.py" hl_lines="8-14" +```python title="client.py" hl_lines="8-13" --8<-- "docs_src/client_transports/tutorial003.py" ``` @@ -70,7 +55,25 @@ TLS について 1 点。`httpx2` は、同梱の CA リストではなく、オ HTTP に関わるものはすべて、渡す 1 つの `httpx2.AsyncClient` に集約されています。 !!! info - `httpx2` はおなじみの `httpx` の API をそのまま保っているので、`httpx` を知っていれば、認証、プロキシ、イベントフック、リトライ、接続数の制限のやり方はすでに知っていることになります。SDK はその上に何も足さず、何も引きません。OAuth が差し込まれるのもここです。`httpx2.AsyncClient(auth=OAuthClientProvider(...))` のように書きます。そのフロー全体については **[OAuth クライアント](oauth-clients.md)** を参照してください。 + `httpx2` はおなじみの `httpx` の API をそのまま保っているので、`httpx` を知っていれば、認証、プロキシ、イベントフック、リトライ、接続数の制限のやり方はすでに知っていることになります。SDK はその上に何も足さず、何も引きません。唯一の例外が[リダイレクトの扱い](#redirects)です。OAuth が差し込まれるのもここです。`httpx2.AsyncClient(auth=OAuthClientProvider(...))` のように書きます。そのフロー全体については **[OAuth クライアント](oauth-clients.md)** を参照してください。 + +### リダイレクト {#redirects} + +トランスポートは渡された URL に接続し、そのオリジンにだけ接続します。 + +* 同じスキーム、ホスト、ポートにとどまる `307`/`308` のリダイレクトには従います。同じホスト上での `http://` → `https://` も同様です。よくある `/mcp` → `/mcp/` という末尾スラッシュのリダイレクトはこれでカバーされます。 +* それ以外の場所へのリダイレクトには従い**ません**。呼び出しは次のエラーで失敗します。 + + ```text + MCPError: Redirect to https://other.example.com/mcp not followed; use that URL as the endpoint if it is the intended server + ``` + + その URL が意図したサーバーなら、設定にその URL を書いてください。そうでなければ、サーバーか、その前段にあるプロキシの設定が誤っています。 + +これは、渡すどの `httpx2.AsyncClient` にも当てはまります。その `follow_redirects` の設定は、MCP のリクエストについてはどちらの方向にも参照されません。SDK の OAuth プロバイダーも、自身のリクエストに同じ規則を適用します。 + +!!! tip + `Redirect to http://… not followed: it would downgrade this HTTPS endpoint to plain HTTP` は、サーバーが自身の知らない TLS 終端プロキシの背後にあり、`http://` のリダイレクトを発行していることを意味します。これはサーバー側で直すか(**[デプロイとスケール](../run/deploy.md#behind-a-tls-terminating-proxy)**)、メッセージが示すとおりの正確な `https://…/` の URL を使うことで解決します。 ## stdio {#stdio} @@ -91,6 +94,18 @@ TLS について 1 点。`httpx2` は、同梱の CA リストではなく、オ API キーを必要とするサーバーは、そこでキーを見つけられません。`env=` で明示的に渡してください。それらの変数は許可リストの上にマージされます。上の例で `BOOKSHOP_API_KEY` がしているのがまさにそれです。 +## インメモリ {#in-memory} + +テストでは、デプロイするものも起動するものもありません。サーバーオブジェクトそのものを渡します。 + +```python hl_lines="14" +--8<-- "docs_src/client_transports/tutorial001.py" +``` + +サブプロセスも、ポートも、通信路を流れるバイト列もありません。クライアントとサーバーは同じプロセス内の 2 つのオブジェクトですが、呼び出しは本物のプロトコル層を通ります。`search_books` は、HTTP 越しの場合とまったく同じように一覧に載り、検証され、呼び出されます。**[テスト](../get-started/testing.md)** のページは、このパターンを中心に組み立てられています。 + +同じ形は組み込み用の API としても使えます。サーバーを自分で構築するアプリケーションなら、ネットワーク越しの経路なしにそのツールを呼び出せます。 + ## SSE {#sse} `mcp.client.sse` の `sse_client(url)` は、Streamable HTTP に取って代わられた HTTP トランスポートです。まだこれを話すサーバーと対話するには、同じように `Client(sse_client("http://localhost:8000/sse"))` と包みます。そして、新しいものをこの上に作らないでください。 @@ -99,15 +114,16 @@ TLS について 1 点。`httpx2` は、同梱の CA リストではなく、オ `Client` から見れば、上記はすべて同じものです。 -**トランスポート**とは、`(read, write)` というメッセージストリームのペアを yield する非同期コンテキストマネージャーのことです。正式には `mcp.client` の `Transport` プロトコルです。`Client` は引数を型で解決します。サーバーオブジェクトならインプロセスで接続し、`str` なら `streamable_http_client(url)` になり、`StdioServerParameters` なら `stdio_client(params)` になり、それ以外は直接トランスポートとして入ります。この最後の規則があるからこそ、`stdio_client(...)`、`streamable_http_client(...)`、`sse_client(...)` はすべて同じ場所に収まり、自分で独自のものを書くこともできます。 +**トランスポート**とは、`(read, write)` というメッセージストリームのペアを yield する非同期コンテキストマネージャーのことです。正式には `mcp.client` の `Transport` プロトコルです。`Client` は引数を型で解決します。`str` なら `streamable_http_client(url)` になり、`StdioServerParameters` なら `stdio_client(params)` になり、サーバーオブジェクトならインプロセスで接続し、それ以外は直接トランスポートとして入ります。この最後の規則があるからこそ、`stdio_client(...)`、`streamable_http_client(...)`、`sse_client(...)` はすべて同じ場所に収まり、自分で独自のものを書くこともできます。 ## まとめ {#recap} -* `Client(mcp)`(サーバーオブジェクト)はインメモリで接続します。テストと組み込みに使ってください。 * `Client("http://.../mcp")`(URL)は、本番用のトランスポートである Streamable HTTP で接続します。 * ヘッダー、認証、プロキシ、タイムアウトは、`streamable_http_client(url, http_client=...)` に渡す `httpx2.AsyncClient` に設定します。`headers=` キーワードはありません。 +* リダイレクトに従うのは、URL 自身のオリジン内(末尾スラッシュの `307`/`308`)と、同じホスト上の `http`→`https` だけです。それ以外は `Redirect to … not followed` で失敗します。最終的な URL を設定してください。 * stdio は `Client(StdioServerParameters(...))` です。自分で `stdio_client(...)` に包むのは、子プロセスの stderr をリダイレクトしたいときだけです。 * サブプロセスが受け取るのは自分の環境ではなく、許可リストに基づく環境です。`env=` でそこに追加します。 +* `Client(mcp)`(サーバーオブジェクト)はインメモリで接続します。テストで使うか、サーバーを構築したアプリケーションにそのサーバーを組み込むために使ってください。 * トランスポートとは、`async with x as (read, write)` と書けるものすべてです。`Client` は、サーバーオブジェクトでも URL でも `StdioServerParameters` でもないものを、そのままこのプロトコルに渡します。 * `Client` の構築でトランスポートが選ばれ、`async with` でそれが開かれます。 diff --git a/i18n/ja/pages/deprecated.md b/i18n/ja/pages/deprecated.md index 8754ecc8..15d9942f 100644 --- a/i18n/ja/pages/deprecated.md +++ b/i18n/ja/pages/deprecated.md @@ -1,11 +1,11 @@ --- translation: - sections: [490237e61c3a7a44, 01262a123ad9501d, 429db5b574a2ac08, e2d0d273fbd2d74b, 64ab0331e868f3d4, 6c8878ce2d1f6d56, 4068f23e371bf0b3, eaef75b8725bc931] + sections: [4c1dea378b2b1bf7, 01262a123ad9501d, 429db5b574a2ac08, e2d0d273fbd2d74b, 64ab0331e868f3d4, 6c8878ce2d1f6d56, c3d1099701156881, e185a1b8e53669f6] tool: 1 --- # 非推奨の機能 {#deprecated-features} -2026-07-28 の仕様では、5 つのものが役目を終えます。SDK は今もその 5 つすべてを実装しており、そのすべてに**非推奨の警告**が付くようになりました。SDK のヘルパーが 1 つ、仕様とは別の理由で非推奨になっており、[ページの最後](#deprecated-sdk-helpers)に挙げています。 +2026-07-28 の仕様では、5 つのものが役目を終えます。SDK は今もその 5 つすべてを実装しており、そのすべてに**非推奨の警告**が付くようになりました。これとは別に、SDK 独自の理由で非推奨になったものもいくつかあり、[ページの最後](#deprecated-sdk-helpers)に挙げています。 下の表は、非推奨になった機能それぞれについて、なくなる理由と、代わりに土台にすべきものを挙げています。 @@ -125,11 +125,13 @@ API はこれだけです。メソッドごとのスイッチはありません ## 非推奨の SDK ヘルパー {#deprecated-sdk-helpers} -これらは仕様の変更ではなく、よりよい代替がある SDK の内部実装にすぎません。同じ `MCPDeprecationWarning` で警告し、3.0 で削除されます。 +これらは仕様の変更ではなく、よりよい代替がある SDK の使い方にすぎません。同じ `MCPDeprecationWarning` で警告し、3.0 で古い形式が削除されます。 | 非推奨 | 代わりにすること | |---|---| | `FuncMetadata.call_fn_with_arg_validation()` | `FuncMetadata.validate_arguments()` を呼んでから `FuncMetadata.call_fn()` を呼びます。これを呼んでいたのは、`FuncMetadata` を直接扱うコード(たとえば独自の `Tool` サブクラス)だけです。 | +| `validate_token_resource=` を指定しない `AuthSettings(resource_server_url=...)` | 指定してください。`True` にすると、ベリファイアーが `resource_server_url` 向けに発行されたと報告しないベアラートークンをサーバーが拒否します。`False` は、ベリファイアーがトークンのオーディエンスを自分で検証することを表します(**[認可](run/authorization.md#a-token-verifier)** を参照)。未指定の場合は `False` として振る舞います。3.0 では、`resource_server_url` が設定されているときは常に `True` がデフォルトになります。 | +| `issuer=` を指定しない `ClientCredentialsOAuthProvider(...)` または `PrivateKeyJWTOAuthProvider(...)` | 資格情報を発行した認可サーバーを指す `issuer=` を渡してください(**[OAuth クライアントの作成](client/oauth-clients.md#machine-to-machine)** を参照)。指定しないと、どの認可サーバーが資格情報を受け取るかを MCP サーバーが決めることになります。3.0 ではこのキーワードが必須になります。 | ## まとめ {#recap} @@ -138,7 +140,7 @@ API はこれだけです。メソッドごとのスイッチはありません * 非推奨は勧告にすぎません。通信上の変更はなく、2026 年より前のセッションに対してはすべてが引き続き動作します。そして目に見える `MCPDeprecationWarning` が出ます(`UserWarning` なので、デフォルトで有効です)。 * サンプリングとルートにはさらに、2026-07-28 のセッションにはないバックチャネルが必要です。現行仕様の接続では警告を出し、そのあと例外を送出します。 * `warnings.filterwarnings("ignore", category=MCPDeprecationWarning)` でカテゴリ全体を黙らせます。pytest で `"error::mcp.MCPDeprecationWarning"` を指定すれば、テストの失敗に変わります。 -* SDK のヘルパー `FuncMetadata.call_fn_with_arg_validation()` は、これとは別に非推奨になっており、3.0 で削除されます。 +* [SDK 独自の非推奨](#deprecated-sdk-helpers)も同じルールに従います。今は警告を出し、3.0 で古い形式がなくなります。 * 新しいコードは、これらのどれの上にも築くべきではありません。 このドキュメントのほかのページはすべて、現行の API を扱っています。 diff --git a/i18n/ja/pages/get-started/first-steps.md b/i18n/ja/pages/get-started/first-steps.md index 01798d6d..d12904a7 100644 --- a/i18n/ja/pages/get-started/first-steps.md +++ b/i18n/ja/pages/get-started/first-steps.md @@ -1,6 +1,6 @@ --- translation: - sections: [0d6c05bcbf836bf3, 59a7b14eeefc68c1, 7114d8d6daba203f, e8bbb56a98ba7bc9, 5138010f6159901c, f78da7c7c363d4c6, 220a939cab348686] + sections: [0d6c05bcbf836bf3, 9a78b5f6b44b18ab, 7114d8d6daba203f, e8bbb56a98ba7bc9, bfd2fd1153e71dac, 1615a994ef071fdd, 65c599fae991f245] tool: 1 --- # 最初のステップ {#first-steps} @@ -17,7 +17,7 @@ translation: * **クライアント**はホストの中にあり、MCP を話します。ホストは、接続するサーバーごとにクライアントを 1 つずつ動かします。 * **サーバー**は、この SDK で作るものです。クライアントに対して何かを公開します。モデルと直接やり取りすることは決してありません。 -自分で書くのはサーバーです。ホストは別の誰かが作る製品です。SDK には `Client` も用意されています。サーバーのテストに使うもので、このページの後半にも登場します。 +自分で書くのはサーバーです。ホストは別の誰かが作る製品です。SDK には `Client` も用意されています。ホストが URL でサーバーに接続したり、サブプロセスとして起動したりするときに使うのと同じクラスです。このページの後半に登場しますし、サーバーをテストする手段にもなります。 ## 3 つのプリミティブ {#the-three-primitives} @@ -79,22 +79,20 @@ Inspector にはタブが 3 つありました。3 つあると、どうやっ クライアントが接続すると、サーバーは自身の**ケイパビリティ**を宣言します。どの系統のリクエストに応答するか、ということです。クライアントはこの宣言をもとに、そもそも何を要求するかを決めます。この宣言を自分で書いてはいません。`MCPServer` が代わりに宣言します。 -自分の目で確かめてみましょう。SDK の `Client` はサーバーオブジェクトをそのまま受け取り、**インメモリ**で接続します(サブプロセスもポートも使いません)。 +自分の目で確かめてみましょう。1 つのターミナルで `server.py` を HTTP で実行したままにしておきます。 -```python -import asyncio +```console +uv run mcp run server.py --transport streamable-http +``` -from mcp import Client +そして別のターミナルから、クライアントをそこに向けます。 -from server import mcp +```python title="client.py" hl_lines="7-8" +--8<-- "docs_src/first_steps/tutorial001_client.py" +``` - -async def main() -> None: - async with Client(mcp) as client: - print(client.server_capabilities.model_dump(exclude_none=True)) - - -asyncio.run(main()) +```console +python client.py ``` ```text @@ -114,7 +112,7 @@ asyncio.run(main()) ここにないものにも注目してください。`completions`(リソーステンプレートとプロンプトの引数の自動補完)には自分で書くハンドラーが必要ですが、このサーバーにはありません。そのためこのケイパビリティは宣言されず、行儀のよいクライアントなら要求もしません。オプションのものはすべてこのルールに従います。登録すればケイパビリティが現れます。**[補完](../servers/completions.md)** のページがそれを実証しています。 !!! info - `Client(mcp)` は、このドキュメントのすべてのサンプルをテストしているのと同じインメモリクライアントで、自分のサーバーをテストするときにもこれを使います。まるごと 1 ページを割いています。**[テスト](testing.md)** です。 + この `client.py` は完全な MCP クライアントで、専用のページが **[クライアント](../client/index.md)** です。テストではターミナルもポートも省き、`Client` にサーバーオブジェクトそのものを渡します。`Client(mcp)` です。こちらにもまるごと 1 ページを割いています。**[テスト](testing.md)** です。 ## 書かなかったもの {#what-you-did-not-write} @@ -123,7 +121,7 @@ asyncio.run(main()) * JSON Schema。`a: int, b: int` がそのまま `add` のスキーマです。 * リクエストハンドラー。`tools/list`、`resources/read`、`prompts/get` は、すべて代わりに処理されます。 * ケイパビリティの宣言。`MCPServer` が代わりに作りました。 -* プロトコルのコードを 1 行も。バージョンのネゴシエーション、JSON-RPC のフレーミング、ケイパビリティの交換は、すべて `mcp dev` と `Client(mcp)` の内部で行われ、目にすることはありませんでした。 +* プロトコルのコードを 1 行も。バージョンのネゴシエーション、JSON-RPC のフレーミング、ケイパビリティの交換は、すべて `mcp dev` と `client.py` の内部で行われ、目にすることはありませんでした。 この比率こそが、この SDK の存在意義です。 @@ -134,6 +132,6 @@ asyncio.run(main()) * デコレーターはプリミティブごとに 1 つです。`@mcp.tool()`、`@mcp.resource(uri)`、`@mcp.prompt()`。名前、説明、スキーマは関数から取られます。 * `{param}` を含む URI はリソース**テンプレート**を作り、具体的なリソースとは別に一覧表示されます。 * サーバーの**ケイパビリティ**は代わりに宣言され、クライアントはサーバーが宣言したものだけを要求します。 -* `Client(mcp)` はサーバーオブジェクトにインメモリで接続します。初日から使えるテストハーネスです。 +* `Client("http://localhost:8000/mcp")` は実行中のサーバーとやり取りします。代わりにサーバーオブジェクトを渡して `Client(mcp)` とすれば、初日から使えるテストハーネスになります。 次は **[実際のホストに接続する](real-host.md)** です。このサーバーを Claude Desktop や IDE の中で、本当に動かします。その次は **[テスト](testing.md)** です。1 ページ、インメモリクライアント 1 つで、動くかどうかを当て推量することはもうありません。そのあとは各プリミティブに専用のページがあり、まずはモデルが動かすもの、**[ツール](../servers/tools.md)** から始まります。 diff --git a/i18n/ja/pages/get-started/testing.md b/i18n/ja/pages/get-started/testing.md index 2636eec9..2b962251 100644 --- a/i18n/ja/pages/get-started/testing.md +++ b/i18n/ja/pages/get-started/testing.md @@ -1,13 +1,13 @@ --- translation: - sections: ['4926721070127497', c52a1de2b6b32f40, 8e792bf8c7489ec6, 627195f7159e24ef] + sections: [5d13c2f0ba42c0d2, c52a1de2b6b32f40, 8e792bf8c7489ec6, 38552ea228b0a04f] tool: 1 --- # テスト {#testing} -Python SDK には、**インメモリトランスポート**を備えた `Client` クラスが付属しています。サーバーオブジェクトを渡せば、そのサーバーに直接接続します。 +SDK の `Client` クラスは、URL に接続したりサブプロセスを起動したりするのと同じクラスですが、**インメモリ**でも接続できます。サーバーオブジェクトを渡せば、そのサーバーと直接やり取りします。 -サブプロセスも、ポートも要りません。トランスポートすら使いません。FastAPI の `TestClient` と同じ発想です。 +サブプロセスも、ポートも要りません。通信路には何も流れません。FastAPI の `TestClient` と同じ発想です。 ## 基本的な使い方 {#basic-usage} @@ -86,10 +86,10 @@ async def test_call_add_tool(client: Client): テストでは有効にしたままにしてください。本番コードでは意味を持ちません。 -## デフォルトはインプロセス {#in-process-by-default} +## デフォルトでは世代を問わない {#era-neutral-by-default} !!! note - `Client(mcp)` はインプロセスで接続し、デフォルトでは**プロトコルの世代を問いません**。サーバーを調べ、適切なプロトコル経路を選びます。テストがレガシー固有のセマンティクス(サンプリングやエリシテーション(elicitation)のプッシュ、`message_handler`)を検証する場合は `mode="legacy"` に固定し、その場合は `raise_exceptions=True` を外してください。レガシー接続はそもそもサニタイズを行わず、このフラグを付けると失敗がテストの中ではなくサーバータスクの中で再送出されてしまうからです。 + `Client(mcp)` はインプロセスで接続し、デフォルトでは**プロトコルの世代を問いません**。サーバーを調べ、適切なプロトコル経路を選びます。テストがレガシー固有のセマンティクス(サンプリングやエリシテーション(elicitation)のプッシュ、`message_handler`)を検証する場合は `mode="legacy"` に固定し、そのときは `raise_exceptions=True` を外してください。レガシー接続はそもそもサニタイズを行わず、このフラグを付けると失敗がテストの中ではなくサーバータスクの中で再送出されてしまうからです。 この 1 行こそが、このドキュメントが「掲載している例は動く」と約束できる理由でもあります。すべてのサンプルファイルは SDK 自身のテストスイートで実行されており、そのほぼすべてがまさにこのクライアントを経由しています。SDK が自分自身に対して使っているのと同じツールを使っているわけです。 diff --git a/i18n/ja/pages/handlers/multi-round-trip.md b/i18n/ja/pages/handlers/multi-round-trip.md index dc7bb0a4..20126801 100644 --- a/i18n/ja/pages/handlers/multi-round-trip.md +++ b/i18n/ja/pages/handlers/multi-round-trip.md @@ -1,6 +1,6 @@ --- translation: - sections: [74011e683045eea9, 9b64cc175c18b6a9, 4b41be4824030397, e3b1502da786ec33, 71e41161f143c6a9, 9ec2c1eeb8c36378, 8dd027377d46448b, f81491125dcbfe8b] + sections: [74011e683045eea9, 9b64cc175c18b6a9, 4b41be4824030397, e3b1502da786ec33, 71e41161f143c6a9, 9ec2c1eeb8c36378, b47667184ca5b516, f81491125dcbfe8b] tool: 1 --- # マルチラウンドトリップ(multi-round-trip)リクエスト {#multi-round-trip-requests} @@ -162,7 +162,7 @@ TTL、プリンシパルの束縛、リクエストの束縛はコーデック ## 2026-07-28 の結果型 {#a-2026-07-28-result} -`InputRequiredResult` はプロトコルバージョン **2026-07-28** にしか存在しません。インメモリの `Client(server)` はそれを代わりにネゴシエートしてくれます。通信路越しでは `mode="auto"` がそれを検出します。接続後、`client.protocol_version` で何が得られたかが分かります。 +`InputRequiredResult` はプロトコルバージョン **2026-07-28** にしか存在しません。`Client` のデフォルトの `mode="auto"` は、どの接続でもそれを検出します。接続後、`client.protocol_version` で何が得られたかが分かります。 !!! warning 2026 より前のセッションには `InputRequiredResult` を入れる場所がありません。`mode="legacy"` の接続でハンドラーからこれを返すと、ランナーはネゴシエートされたバージョンにシリアライズできず、クライアントには `-32603` *"Handler returned an invalid result"* エラーが返ります。両方の世代に対応するサーバーは、これを使う前に `ctx.protocol_version` をチェックしなければなりません。 diff --git a/i18n/ja/pages/handlers/progress.md b/i18n/ja/pages/handlers/progress.md index 6d695d1d..9729ced6 100644 --- a/i18n/ja/pages/handlers/progress.md +++ b/i18n/ja/pages/handlers/progress.md @@ -1,6 +1,6 @@ --- translation: - sections: [5315262fe26b33e1, 9d8e98840f1b78f0, 0284b215e85366c4, 8534d8dbb4053a70, 2966fac6fe697007] + sections: [5315262fe26b33e1, 9d8e98840f1b78f0, 52d6009a07e770ea, 8534d8dbb4053a70, 2966fac6fe697007] tool: 1 --- # 進捗 {#progress} @@ -29,19 +29,17 @@ translation: クライアントは、`call_tool` に `progress_callback=` を渡すことで、**呼び出しごとに**オプトインします。 -```python title="client.py" hl_lines="7 16" +```python title="client.py" hl_lines="5 14" import anyio from mcp import Client -from server import mcp - async def show(progress: float, total: float | None, message: str | None) -> None: print(f"{message} ({progress}/{total})") async def main() -> None: - async with Client(mcp) as client: + async with Client("http://localhost:8000/mcp") as client: result = await client.call_tool( "import_catalog", {"urls": ["https://example.com/a.json", "https://example.com/b.json"]}, @@ -56,23 +54,27 @@ anyio.run(main) コールバックは `async` 関数で、サーバーが報告したものをそのまま受け取ります。`progress`、`total`、`message` です。 !!! info - `Client(mcp)` はサーバーオブジェクトにメモリ内で直接接続します。**[テスト](../get-started/testing.md)** のページの土台になっているのと同じクライアントです。`progress_callback` は、`Client` がどのトランスポートを使っていても同じパラメーターです。これから目にする「タイミング」はメモリ内接続のものです。メモリ内接続はコールバックをインラインで実行するため、すべての報告が `call_tool` が返る前に届きます。実際のトランスポートでは通知と結果の到着順は保証されず、遅いコールバックは `call_tool` が返ったあともまだ実行中のことがあります。 + `progress_callback` は、`Client` に何を渡したかにかかわらず同じパラメーターです。ここでのような URL でも、`StdioServerParameters` でも、テストでのサーバーオブジェクトでも変わりません。ただし、実際のトランスポートではタイミングに注意してください。通知はそれぞれレスポンスとは別に単独で届くため、遅いコールバックは `call_tool` が返ったあともまだ実行中のことがあります。コールバックをインラインで実行し、すべての報告が先に届くことを保証するのは、プロセス内のテスト接続だけです。 ### 試してみる {#try-it} -`client.py` を `server.py` の隣に置いて、実行してください。 +`server.py` を HTTP で配信し、別のターミナルからクライアントを実行してください。 + +```console +uv run mcp run server.py --transport streamable-http +``` ```console python client.py ``` ```text -Imported https://example.com/a.json (1/2) -Imported https://example.com/b.json (2/2) +Imported https://example.com/a.json (1.0/2.0) +Imported https://example.com/b.json (2.0/2.0) {'result': 'Imported 2 records.'} ``` -サーバー側の `await ctx.report_progress(...)` はそれぞれ、クライアント側で順番どおりに `show` の 1 回の呼び出しになり、2 行とも `call_tool` が返る**前に**出力されました。進捗は結果にまとめられるのではなく、ツールがまだ動いている間にストリーミングされます。 +サーバー側の `await ctx.report_progress(...)` はそれぞれ、クライアント側で順番どおりに `show` の 1 回の呼び出しになりました。進捗は結果にまとめられるのではありません。ツールがまだ動いている間にストリーミングされます。 !!! warning `progress_callback` は `Client` ではなく、**呼び出し**に属します。そのためのコンストラクター引数はありません。呼び出しごとに必要なコールバックが違うからです。ある呼び出しはダウンロードバーを動かし、次の呼び出しはログの 1 行を出します。 diff --git a/i18n/ja/pages/protocol-versions.md b/i18n/ja/pages/protocol-versions.md index 1e2a6b51..8eb5f6bf 100644 --- a/i18n/ja/pages/protocol-versions.md +++ b/i18n/ja/pages/protocol-versions.md @@ -1,6 +1,6 @@ --- translation: - sections: [478fd619e5f90ef8, aef094a00e44e248, bab8cbf3449fa7e9, df1809b15a58335b, 5f9d8c2336ed0239, f54974398e43ddef, b24443dd78584870] + sections: [d98e337bf28845e0, dd642acbba36f615, f547b3e76da19c94, 149513378588da5c, 4e149ed992046709, f54974398e43ddef, b24443dd78584870] tool: 1 --- # プロトコルバージョン {#protocol-versions} @@ -11,9 +11,17 @@ MCP には 2 つの世代があります。 `Client` が代わりにネゴシエーションしてくれるので、気にする必要はほとんどありません。このページで扱うのは、それを制御するたった 1 つのコンストラクター引数 `mode=` と、それを変更する 3 つの場面です。 +このページのスニペットはどれも、**[クライアント](client/index.md)** に出てくる Bookshop の `server.py` と通信する `client.py` です。まず 1 つ目のターミナルでそのサーバーを起動してください。 + +```console +uv run mcp run server.py --transport streamable-http +``` + +次に、2 つ目のターミナルで各スニペットを `python client.py` で実行します。 + ## `mode="auto"` {#modeauto} -```python title="client.py" hl_lines="14-15" +```python title="client.py" hl_lines="7-8" --8<-- "docs_src/protocol_versions/tutorial001.py" ``` @@ -31,11 +39,11 @@ MCP には 2 つの世代があります。 機能としてはこれだけです。`Client` は 1 つ、サーバーはどの世代でもよく、コードに分岐は要りません。 !!! info - `MCPServer` はインメモリ、stdio、Streamable HTTP のどのトランスポートでも `server/discover` に応答します。そのため、自分のサーバーが相手なら `auto` は必ず `2026-07-28` になります。フォールバックが発動するのは 2026 年より前の本物のサーバーが相手のときだけで、それはまさにフォールバックしてほしい場面です。 + `MCPServer` は Streamable HTTP、stdio、そしてテストで使うインプロセス接続のどのトランスポートでも `server/discover` に応答します。そのため、自分のサーバーが相手なら `auto` は必ず `2026-07-28` になります。フォールバックが発動するのは 2026 年より前の本物のサーバーが相手のときだけで、それはまさにフォールバックしてほしい場面です。 ## `mode="legacy"` {#modelegacy} -```python title="client.py" hl_lines="14" +```python title="client.py" hl_lines="7" --8<-- "docs_src/protocol_versions/tutorial002.py" ``` @@ -59,7 +67,7 @@ MCP には 2 つの世代があります。 `mode` には新世代のプロトコルバージョン文字列も指定できます。現時点でその集合はちょうど `["2026-07-28"]` です。 -```python title="client.py" hl_lines="14" +```python title="client.py" hl_lines="7" --8<-- "docs_src/protocol_versions/tutorial003.py" ``` @@ -90,7 +98,7 @@ ValueError: mode must be 'legacy', 'auto', or one of ['2026-07-28']; got '2025-0 なので取っておきましょう。`auto` で接続した後、`client.session.discover_result` にはサーバーが送った `DiscoverResult` がそのまま入っています。`supported_versions`、`capabilities`、`instructions`、そしてサーバーが結果の `_meta` に刻んだ識別情報です。次回はそれを `prior_discover=` として渡します。 -```python title="client.py" hl_lines="15 17" +```python title="client.py" hl_lines="8 10" --8<-- "docs_src/protocol_versions/tutorial004.py" ``` diff --git a/i18n/ja/pages/run/asgi.md b/i18n/ja/pages/run/asgi.md index f49b8864..7853bd4d 100644 --- a/i18n/ja/pages/run/asgi.md +++ b/i18n/ja/pages/run/asgi.md @@ -1,6 +1,6 @@ --- translation: - sections: [1062ef792791488a, 4be2b831547184a9, 374b049e770385f2, b72f6947089e6de0, b172c9db7831bb31, 70b9ece244ca1b0c, cba78e052898c3f6, f06bdb541cb0b469, fb82d526320b7cc3] + sections: [1062ef792791488a, 4be2b831547184a9, 374b049e770385f2, b72f6947089e6de0, b172c9db7831bb31, 10394c6f16601638, cba78e052898c3f6, f06bdb541cb0b469, 6dc898ccc5a903f9] tool: 1 --- # 既存のアプリに組み込む {#add-to-an-existing-app} @@ -86,7 +86,7 @@ Starlette の `Host` ルートも同じように動きます。`Mount("/", ...)` --8<-- "docs_src/asgi/tutorial004.py" ``` -これでクライアントは `/notes/mcp` ではなく `/notes` に接続します。 +これでクライアントは `/notes/mcp` ではなく `/notes/` に接続します。 ## ブラウザークライアント向けの CORS {#cors-for-browser-clients} @@ -127,4 +127,4 @@ Starlette の `Host` ルートも同じように動きます。`Mount("/", ...)` * ブラウザークライアントには CORS が必要です。`Mcp-*` リクエストヘッダーのための `allow_headers` と、レスポンスのための `expose_headers=["Mcp-Session-Id"]` です。 * `@mcp.custom_route()` は、認証なしの素の HTTP エンドポイントを `/mcp` の隣に追加します。 -サーバーに実際の URL で到達できるようになったら、**[クライアント](../client/index.md)** はサーバーオブジェクトの代わりにその URL を使って接続します。 +サーバーに実際の URL で到達できるようになったら、**[クライアント](../client/index.md)** はその URL を使ってサーバーに接続します。 diff --git a/i18n/ja/pages/run/authorization.md b/i18n/ja/pages/run/authorization.md index a1795ada..df1f0470 100644 --- a/i18n/ja/pages/run/authorization.md +++ b/i18n/ja/pages/run/authorization.md @@ -1,6 +1,6 @@ --- translation: - sections: [d62c13457fc4a534, 80e73abaca6e0652, d1dc4c54cd00ec9c, 14ad3bc7904036bb, 5225f127bc1b9c77, fe1626fdd5aad1da, 4556cb7ea1a04a31] + sections: [d62c13457fc4a534, 80e73abaca6e0652, 128a492d18295f64, 14ad3bc7904036bb, 54a6697833fcc1d9, fe1626fdd5aad1da, 811d083c1da8bcf6] tool: 1 --- # 認可 {#authorization} @@ -23,12 +23,12 @@ OAuth の用語でいえば、サーバーは**リソースサーバー**です 有効なトークンがどんな形をしているかについて、SDK は何の前提も持ちません。**`TokenVerifier`** を実装して、こちらから伝えます。 -```python title="server.py" hl_lines="12-14 19-24" +```python title="server.py" hl_lines="14-16 21-27" --8<-- "docs_src/authorization/tutorial001.py" ``` * `TokenVerifier` は非同期メソッドを 1 つだけ持つプロトコルです。`verify_token` は `Authorization` ヘッダーから取り出した生のトークンを受け取り、有効なら **`AccessToken`** を、無効なら `None` を返します。実装するものはほかにありません。 -* この例ではトークンをテーブルから引いています。実際のものは JWT の署名を検証するか、認可サーバーのトークンイントロスペクションエンドポイントを呼び出します。そのコードは自分で書きます。SDK はそれを呼び出すだけです。 +* この例ではトークンをテーブルから引いています。各エントリには、そのトークンがどのリソース向けに発行されたかが記録されています。実際のものは JWT の署名を検証するか、認可サーバーのトークンイントロスペクションエンドポイントを呼び出し、トークンが誰向けに発行されたか(`aud`)を `AccessToken.resource` で報告します。そのコードは自分で書きます。SDK はそれを呼び出すだけです。 * `token_verifier=` と `auth=` は必ずセットで渡します。片方だけ渡すと、`MCPServer(...)` はリクエストを 1 つも処理しないうちに `ValueError` を送出します。 `AuthSettings` はリソースサーバーの表向きの顔です。 @@ -36,6 +36,10 @@ OAuth の用語でいえば、サーバーは**リソースサーバー**です * `issuer_url`:トークンを発行する認可サーバー。 * `resource_server_url`:この MCP エンドポイントの公開 URL。トークンが「どの」リソース向けかを示す名前であり、ディスカバリードキュメントが置かれる場所でもあります。 * `required_scopes`:すべてのトークンがこれらをすべて持っている必要があります。 +* `validate_token_resource`:`AccessToken.resource` が `resource_server_url` でないトークンをすべて拒否します。`resource_server_url` を設定したままこれを未設定にすると警告(`MCPDeprecationWarning`)が出て、`False` として動作します。3.0 ではリソースサーバーのデフォルトが `True` になります。 + * 認可サーバーが、クライアントの要求した `resource` にトークンを結び付ける場合は有効にしてください。MCP クライアントはこれを常に送ります。`resource_server_url` は、クライアントが接続する URL と正確に一致させてください。 + * 認可サーバーが独自のオーディエンス識別子(Auth0 の API 識別子、Entra のアプリケーション ID)を使う場合は無効のままにし、代わりにベリファイアーの中で `aud` を確認して、このサーバー向けでないトークンには `None` を返してください。 + * `aud` がリストの場合は、`resource_server_url` と等しいエントリを `resource` に入れてください。 !!! tip SDK リポジトリの `examples/servers/simple-auth/` には、実際の認可サーバーの [RFC 7662](https://datatracker.ietf.org/doc/html/rfc7662) エンドポイントを呼び出す `IntrospectionTokenVerifier` があります。本番用のベリファイアーの多くはこの形になります。 @@ -85,7 +89,7 @@ OAuth の用語でいえば、サーバーは**リソースサーバー**です どのハンドラーの中でも、**`get_access_token()`** は現在のリクエストに対してベリファイアーが返した `AccessToken` です。 -```python title="server.py" hl_lines="4 32-35" +```python title="server.py" hl_lines="4 35-38" --8<-- "docs_src/authorization/tutorial002.py" ``` @@ -117,6 +121,6 @@ SDK が提供するのはリソースサーバーの半分、つまり検証、 * `token_verifier=` と `auth=AuthSettings(issuer_url=..., resource_server_url=..., required_scopes=[...])` は必ずセットで渡します。 * SDK は [RFC 9728](https://datatracker.ietf.org/doc/html/rfc9728) Protected Resource Metadata を `/.well-known/oauth-protected-resource/...` で公開し、未認証のリクエストには、そこを指す `WWW-Authenticate` ヘッダー付きの 401 で応答します。ディスカバリーの仕組みはこれだけです。 * どのハンドラーでも、`get_access_token()` を呼べば誰が呼び出しているかがわかります。 -* 認可は HTTP の関心事です。`stdio` とインメモリクライアントがそれを目にすることはありません。 +* 認可は HTTP の関心事です。`stdio` とインメモリのテストクライアントがそれを目にすることはありません。 クライアント側の半分(認可サーバーを見つけてトークンを取得してくれる部分)については、**[OAuth クライアント](../client/oauth-clients.md)**を参照してください。そして、ユーザーに尋ねる代わりに ID を「アサート」するクライアントについては、**[ID アサーション](../client/identity-assertion.md)**を参照してください。 diff --git a/i18n/ja/pages/run/deploy.md b/i18n/ja/pages/run/deploy.md index dd665f9e..6a1529a2 100644 --- a/i18n/ja/pages/run/deploy.md +++ b/i18n/ja/pages/run/deploy.md @@ -1,6 +1,6 @@ --- translation: - sections: [28221886b198784f, f88ea1f1614f3a1d, ce926d686730b6d0, 3be24f8ad8bb5ab9, 3fad24032b2224ff, f25a7f860e579ecb, e758745df6fb7b0a] + sections: [28221886b198784f, f88ea1f1614f3a1d, 2e76f5cb9df15042, ce926d686730b6d0, 3be24f8ad8bb5ab9, 3fad24032b2224ff, f25a7f860e579ecb, 697b01d95080880d] tool: 1 --- # デプロイとスケール {#deploy-scale} @@ -42,6 +42,22 @@ translation: この文言はクライアント側では見つかりません。`421` は JSON-RPC エラーではなくプレーンテキストの HTTP レスポンスなので、MCP クライアントは汎用的なトランスポートエラーを送出します。気に入らなかったホスト名は**サーバー**のログに、警告として 1 行出るだけです。デプロイしたばかりのサーバーがすべての接続を拒否するなら、そうでないと証明されるまでは Host の許可リストが原因です。**[トラブルシューティング](../troubleshooting.md)** もここから始まります。 +## TLS 終端プロキシの背後で {#behind-a-tls-terminating-proxy} + +TLS がプロキシ(イングレス、ロードバランサー、Caddy、nginx)で終端し、その背後で uvicorn が平文の HTTP を配信する場合は、プロキシの `X-Forwarded-*` ヘッダーを信頼するよう uvicorn に指示します。 + +```console +uvicorn server:app --proxy-headers --forwarded-allow-ips='' +``` + +これがないと、アプリは自分が `http://` で配信されていると思い込み、発行するリダイレクト(よくあるのは `/mcp` → `/mcp/`)はすべて `http://…` を指します。Python クライアントは、HTTPS エンドポイントから平文の HTTP へのリダイレクトには従わず、その旨を伝えます。 + +```text +MCPError: Redirect to http://mcp.example.com/mcp/ not followed: it would downgrade this HTTPS endpoint to plain HTTP. +``` + +クライアント側の応急処置は、サーバーが配信する正確な URL(`https://mcp.example.com/mcp/`、末尾のスラッシュ込み)を設定して、リダイレクトが起きないようにすることです。根本的な解決策は上のフラグです。`FORWARDED_ALLOW_IPS` はその環境変数版の書き方です。`*` はすべてのホップを信頼しますが、それが正しいのはプロキシ以外から uvicorn に到達できない場合だけです。 + ## ワーカーと、スティッキーにする必要があるのは誰か {#workers-and-who-has-to-be-sticky} ホスト名が応答するようになったら、その背後に複数のワーカーを置きます。そのための SDK の設定項目はありません。Starlette アプリは、どんな ASGI アプリとも同じ方法でスケールします。fork の仕方を知っているものにオブジェクトを渡すだけです。 @@ -154,6 +170,7 @@ python -c "import secrets; print(secrets.token_hex(32))" ## まとめ {#recap} * デフォルトでは、このアプリは localhost 宛てのリクエストにだけ応答します。`transport_security=TransportSecuritySettings(allowed_hosts=[...], allowed_origins=[...])` が公開時の関門です。これを渡すまでは、本物のホスト名の背後ではすべてのリクエストが `421` になり、理由はサーバーのログにしか出ません。 +* TLS 終端プロキシの背後では、uvicorn を `--proxy-headers --forwarded-allow-ips=...` 付きで実行してください。そうしないとリダイレクトが `http://` を指し、クライアントに拒否されます。 * 2026-07-28 ではセッションはなく、ロードバランサーがスティッキーにすべき対象もありません。`stateless_http=True` がレガシー専用の設定項目なのは、モダンなリクエストはこのフラグが読まれる前にルーティングされ、応答されるからです。 * デフォルトの `requestState` の鍵は、プロセスごとに生成される `os.urandom(32)` です。別のワーカーに届いたマルチラウンドトリップのリトライは、`-32602` *"Invalid or expired requestState"* で失敗します。 * 解決策は `RequestStateSecurity(keys=[...])` **と**、すべてのインスタンスで同じサーバー名にすることです。名前はトークンのデフォルトの audience クレームです。鍵も同じ、名前も同じ。 diff --git a/i18n/ja/pages/run/index.md b/i18n/ja/pages/run/index.md index feaac858..7dc38bee 100644 --- a/i18n/ja/pages/run/index.md +++ b/i18n/ja/pages/run/index.md @@ -1,6 +1,6 @@ --- translation: - sections: [fea8d769ff9edeba, ce8e2ad42f29ef71, 0d705efb19cf99c2, 2fd7cf825e6d2b2c, 9adc400e8c88e854, 318893ad8e2e9924, 6b63ab96b34476c0] + sections: [fea8d769ff9edeba, ce8e2ad42f29ef71, 0d705efb19cf99c2, 9fd2357154a5b7e7, 9adc400e8c88e854, 318893ad8e2e9924, 6b63ab96b34476c0] tool: 1 --- # サーバーの実行 {#running-your-server} @@ -72,6 +72,8 @@ Inspector は本物のホストとまったく同じことをします。`server * `json_response=True`:各 POST に SSE ストリームではなく単一の JSON ボディで応答します。このボディにはレスポンスしか入る余地がありません。そのため、リクエストの途中でクライアントを呼び返すツール(`ctx.elicit()` やサンプリング)は、この区間で `NoBackChannelError` を送出します。進行中の呼び出しに紐づく通知(`ctx.report_progress()` による進捗や呼び出しごとのログメッセージ)は破棄されますが、独立した `GET` ストリームは無関係な通知を引き続き運びます。 * `stateless_http=True`:リクエストごとに新しいトランスポートを作り、セッションを追跡しません。 * `max_request_body_size`:受け付けるリクエストボディの最大サイズ(バイト単位)です。デフォルトは 4 MiB で、これより大きいリクエストはパースやセッション作成の前に HTTP 413 を受け取ります。正当な MCP メッセージがこのサイズを超える場合にだけ引き上げてください。 +* `session_idle_timeout`:レガシーセッションが処理中のものを何も持たないまま待機できる秒数で、これを過ぎるとサーバーがセッションを閉じます。デフォルトは 1800 です。`None` で無効になります。[セッションの存続期間と上限](legacy-clients.md#session-lifetime-and-limits) を参照してください。 +* `max_sessions`:1 つのプロセスが同時に保持するレガシーセッションの数です。デフォルトは 10 000 です。`None` で上限がなくなります。同じセクションで扱っています。 * `event_store`、`retry_interval`、`transport_security`:再開可能性と DNS リバインディング保護です。localhost 以外の場所にデプロイするまでは後回しでかまいません。`transport_security` については **[デプロイとスケール](deploy.md)** で扱います。 !!! warning diff --git a/i18n/ja/pages/run/legacy-clients.md b/i18n/ja/pages/run/legacy-clients.md index 4de105d5..bf2690d4 100644 --- a/i18n/ja/pages/run/legacy-clients.md +++ b/i18n/ja/pages/run/legacy-clients.md @@ -1,6 +1,6 @@ --- translation: - sections: [3d1663c18edc824c, d4fd37009a13f03d, af9f398a5a8b679a, 470c2dd144294d69, 8e45827e6d24e8c8, 91dfd0ce98ebb03c] + sections: [3d1663c18edc824c, 90956965ae6a1ca1, af9f398a5a8b679a, 5ce83b1f9d88da62, 0d9b5d13fffc94e5, 8e45827e6d24e8c8, 91dfd0ce98ebb03c] tool: 1 --- # レガシークライアントへの対応 {#serving-legacy-clients} @@ -18,15 +18,25 @@ SDK はすべてのリクエストを `MCP-Protocol-Version` ヘッダーで振 ## 1 つのハンドラーで両方の世代 {#one-handler-both-eras} -ユーザーに何かを尋ねる必要があるツールと、それを呼び出す両方の世代のクライアントを示します。 +ユーザーに何かを尋ねる必要があるツールを示します。 -```python title="server.py" hl_lines="24 37-38" +```python title="server.py" hl_lines="21" --8<-- "docs_src/legacy_clients/tutorial001.py" ``` `reserve` には、モデルが渡してこなかったものが 1 つ必要です。何冊予約するかです。ツールはそれを `Annotated[..., Resolve(ask_quantity)]` で宣言します(詳しくは **[依存関係](../handlers/dependencies.md)** を参照してください)。`reserve` の中には、バージョンを指定する箇所も、ケイパビリティを確認する箇所も、分岐する箇所もありません。 -2 つのクライアントは同じ `mcp` オブジェクトに対して**同時に**開かれています。`mode="legacy"` は `initialize` ハンドシェイクを実行します。2026 年より前のクライアントが開くのとまったく同じ接続です。もう一方はデフォルトのままで、`2026-07-28` になります。 +これを HTTP で公開します。続いて、両方の世代のクライアントがそれを呼び出す様子です。 + +```console +uv run mcp run server.py --transport streamable-http +``` + +```python title="client.py" hl_lines="14-15" +--8<-- "docs_src/legacy_clients/tutorial001_client.py" +``` + +2 つのクライアントは、動作中の同じサーバーに対して**同時に**開かれています。`mode="legacy"` は `initialize` ハンドシェイクを実行します。2026 年より前のクライアントが開くのとまったく同じ接続です。もう一方はデフォルトのままで、`2026-07-28` になります。別のターミナルから `python client.py` を実行してください。 ```text 2025-11-25 {'result': "Reserved 2 of 'Dune'."} @@ -53,6 +63,31 @@ SDK はすべてのリクエストを `MCP-Protocol-Version` ヘッダーで振 !!! warning `event_store=` は解決策に見えますが、そうではありません。これは**再開可能性**(「同じ」セッションに再接続するクライアントに、取りこぼした SSE イベントを再送する機能)であって、セッションストアではありません。別のプロセスからセッションに到達できるようにはしません。 +## セッションの有効期間と上限 {#session-lifetime-and-limits} + +レガシーセッションは永遠には生き続けませんし、1 つのプロセスが無制限にセッションを抱えることもありません。これを制御する設定が 2 つあります。どちらも `run()`、`streamable_http_app()`、`Server.streamable_http_app()` のキーワード引数です。モダンな(`2026-07-28` の)接続と `stateless_http=True` にはセッションがないため、どちらの設定も適用されません。 + +| 設定 | デフォルト | 動作 | クライアントから見えるもの | 無効にするには | +|---|---|---|---|---| +| `session_idle_timeout` | `1800`(30 分) | 処理中のものが何もない状態がその時間続いたセッションを閉じます。 | `404 Session not found`。もう一度 `initialize` する必要があります。 | `None` | +| `max_sessions` | `10_000` | その数を超えて新しいセッションを開くことを拒否します。既存のセッションには手を付けず、何も追い出しません。 | `503 Too many open sessions`、JSON-RPC コードは `-32603` です。 | `None` | + +「処理中」と見なされるものは次のとおりです。 + +* 開いている `GET` ストリーム。SDK のクライアントは 1 本を開いたままにするため、接続中のクライアントのセッションが期限切れになることはありません。 +* まだ応答中のリクエスト。タイムアウトより長く動くツール呼び出しが中断されることはなく、カウントダウンはその呼び出しが終わってから始まります。 +* それ以外にはありません。リクエストとリクエストの間は時計が進みます。セッション上のどんなリクエストでも時計はリセットされ、`ping` も例外ではありません。一度期限切れになったセッションを復活させる手段はありません。 + +`DELETE` でセッションを終了したクライアントは、そのセッションを即座に解放します。最初のリクエストが拒否されたクライアントも同様です。 + +```python +mcp.run(transport="streamable-http", session_idle_timeout=None, max_sessions=50_000) +``` + +どちらの出来事もサーバーのログに残ります。期限切れは `INFO` レベルの `Session idle timeout` です。セッションを開くのを拒否した場合は `WARNING` レベルの `Refusing to open a new session: sessions are already open` です。 + +これらの上限はプロセスごとです。ワーカーが 4 つなら上限は `max_sessions` の 4 倍で、各ワーカーは自分のセッションだけを期限切れにします。 + ## 唯一のスイッチ:`stateless_http` {#the-one-knob-stateless_http} スティッキー性というコストを払いたくないなら、変更できるものがちょうど 1 つだけあります。 @@ -73,7 +108,7 @@ SDK はすべてのリクエストを `MCP-Protocol-Version` ヘッダーで振 `json_response=True` はそのスイッチではありませんが、「すべての」レガシーセッションで同じコストの半分を負います。1 つの JSON ボディで応答される `POST` にはリクエストスコープのチャネル用のストリームがないため、リクエスト途中の `ctx.elicit()` は同じ `NoBackChannelError` を送出し、そのリクエストに結び付いた通知は捨てられます。セッションのスタンドアロンストリームには影響しません。無関係な通知は引き続き届きます。 !!! check - あえて間違ったことをしてみましょう。`reserve` は、先ほど両方のクライアントに応答したそのツールです。これを `stateless_http=True` でデプロイし、同じ 2 つのクライアントを HTTP で接続して、それぞれから呼び出してください。 + あえて間違ったことをしてみましょう。`reserve` は、先ほど両方のクライアントに応答したそのツールです。これを `stateless_http=True` でデプロイし、同じ 2 つのクライアントを接続して、それぞれから呼び出してください。 モダンなクライアントには引き続き `Reserved 2 of 'Dune'.` が返ります。モダンな経路は変わっていません。 diff --git a/i18n/ja/pages/troubleshooting.md b/i18n/ja/pages/troubleshooting.md index 77f5d874..d351427d 100644 --- a/i18n/ja/pages/troubleshooting.md +++ b/i18n/ja/pages/troubleshooting.md @@ -1,6 +1,6 @@ --- translation: - sections: [2efaecdef109a5c5, fcacd3e66b8635a4, 25323d737dcf0261, 8a6e351ec756904d, 137454d469c867f5, 6392596bd6df54f0, 41126fa9c4fe432f, 480b6d7897e30ab4, d83bb682e708dde0, ebbed3449c499db4, 323ef84f6b4bebde, 30fd31be74169d9a, 656943c6cb567218, c2dc3b1007d2e987, 7cf5386b997d04e9, 0b59feed8384456e, 0cba47bae78d04eb, e4355f4c7cf4fb2e] + sections: [3d58228e81b99543, 170514ce901c4139, 17d61fad0a50d62b, 8a6e351ec756904d, 137454d469c867f5, 6392596bd6df54f0, 41126fa9c4fe432f, 480b6d7897e30ab4, d83bb682e708dde0, ebbed3449c499db4, 525cdf1755e29d4c, 30fd31be74169d9a, d2e88333d4f7841f, c2dc3b1007d2e987, d6eabf60cc366341, f798e815252852c2, 0cba47bae78d04eb, 2c218ba829abf74e] tool: 1 --- # トラブルシューティング {#troubleshooting} @@ -13,6 +13,12 @@ translation: --8<-- "docs_src/troubleshooting/tutorial001.py" ``` +それらの項目は `http://localhost:8000/mcp` でこのサーバーにアクセスするので、HTTP で起動したままにしておいてください。 + +```console +uv run mcp run server.py --transport streamable-http +``` + このページで引用しているエラーは本物です。SDK 自身のテストスイートが、そのすべてを再現しています。 ## `ExceptionGroup: unhandled errors in a TaskGroup (1 sub-exception)` {#exceptiongroup-unhandled-errors-in-a-taskgroup-1-sub-exception} @@ -23,7 +29,7 @@ translation: ```python async def main() -> None: - async with Client(mcp) as client: + async with Client("http://localhost:8000/mcp") as client: await client.read_resource("weather://Atlantis") ``` @@ -49,7 +55,7 @@ async def main() -> None: ```python async def main() -> None: - async with Client(mcp) as client: + async with Client("http://localhost:8000/mcp") as client: try: await client.read_resource("weather://Atlantis") except MCPError as e: @@ -65,7 +71,7 @@ async def main() -> None: ```python async def main() -> None: - client = Client(mcp) + client = Client("http://localhost:8000/mcp") tools = await client.list_tools() # RuntimeError ``` @@ -73,7 +79,7 @@ async def main() -> None: ```python async def main() -> None: - async with Client(mcp) as client: + async with Client("http://localhost:8000/mcp") as client: tools = await client.list_tools() ``` @@ -245,7 +251,7 @@ app = Starlette(routes=[Mount("/", app=mcp.streamable_http_app())], lifespan=lif ## `MCPError: Session not found` {#mcperror-session-not-found} -クライアントが送った `Mcp-Session-Id` をサーバーが認識していません。ほぼ確実に、サーバーが**再起動した**(または別のインスタンスにルーティングされた)のが原因です。セッションは、その 1 つのプロセスのメモリの中にあります。 +クライアントが送った `Mcp-Session-Id` をサーバーが認識していません。サーバーが**再起動した**(または別のインスタンスにルーティングされた)か、`session_idle_timeout`(デフォルトで 30 分)の間に進行中のものが何もなく、セッションが**期限切れになった**かのどちらかです。[セッションの寿命と上限](run/legacy-clients.md#session-lifetime-and-limits)を参照してください。セッションは、その 1 つのプロセスのメモリの中にあります。 探すべきサーバーのバグはありません。HTTP レスポンスは `404` で、そのボディは JSON-RPC「です」。そのため上の `421` とは違い、python の `Client` はこれをそのまま見せてくれます。 @@ -255,9 +261,9 @@ app = Starlette(routes=[Mount("/", app=mcp.streamable_http_app())], lifespan=lif 直し方は再接続です。`async with Client(...)` ブロックを抜けて新しいブロックに入れば、新しいセッションがネゴシエートされます。長く生きるクライアントであれば、呼び出しの周りで `MCPError` を捕まえ、死んだセッションの中でリトライするのではなく、このメッセージを見たら再接続することになります。 -再起動「なしで」これが起きるなら、スティッキーセッションなしで複数のワーカーを動かしています。ワーカーごとに独自のセッションテーブルを持つので、間違ったワーカーにルーティングされたリクエストはここに行き着きます。この話とその 2 つの直し方(スティッキールーティング、または `stateless_http=True`)は、**[デプロイとスケール](run/deploy.md)** と **[レガシークライアントへの提供](run/legacy-clients.md)** が担当しています。 +再起動「なしで」、かつクライアントがそれほど長く静かだったわけでもないのにこれが起きるなら、スティッキーセッションなしで複数のワーカーを動かしています。ワーカーごとに独自のセッションテーブルを持つので、間違ったワーカーにルーティングされたリクエストはここに行き着きます。この話とその 2 つの直し方(スティッキールーティング、または `stateless_http=True`)は、**[デプロイとスケール](run/deploy.md)** と **[レガシークライアントへの提供](run/legacy-clients.md)** が担当しています。 -サーバー運用者向けには、対応するログ行は `Rejected request with unknown or expired session ID: ` です。`INFO` で記録されるので、通常の `WARNING` のしきい値では見えません。デプロイ直後にまとまって出るのは正常です。接続中のクライアントがすべて再接続しているのです。 +サーバー運用者向けには、対応するログ行は `Rejected request with unknown or expired session ID: ` です。`INFO` で記録されるので、通常の `WARNING` のしきい値では見えません。デプロイ直後にまとまって出るのは正常です。接続中のクライアントがすべて再接続しているのです。セッションが期限切れになった場合は、その行の前に `Session idle timeout` が同じく `INFO` で出ます。 ## `MCPError: Method not found` {#mcperror-method-not-found} @@ -269,7 +275,13 @@ app = Starlette(routes=[Mount("/", app=mcp.streamable_http_app())], lifespan=lif サーバーはユーザーに何かを尋ねたいのに、このクライアントは尋ねられることができると一度も言っていません。 -エリシテーション(elicitation)のリゾルバーは、接続中のクライアントがフォームのエリシテーションを宣言していない場合、最初の時点で拒否します。`e.error.data` には、足りないものが正確に書かれています。 +この Bistro は、予約する前にリゾルバーを通じて尋ねます。 + +```python title="server.py" hl_lines="15-17 21" +--8<-- "docs_src/troubleshooting/tutorial007.py" +``` + +Weather サーバーの代わりにこれを提供し、`elicitation_callback` を渡していないクライアントから `book_table` を呼んでください。接続中のクライアントがフォームのエリシテーション(elicitation)を一度も宣言していないので、リゾルバーは最初の時点で拒否します。`e.error.data` には、足りないものが正確に書かれています。 ```json { @@ -283,7 +295,7 @@ app = Starlette(routes=[Mount("/", app=mcp.streamable_http_app())], lifespan=lif ```python async def main() -> None: - async with Client(mcp, elicitation_callback=handle_elicitation) as client: + async with Client("http://localhost:8000/mcp", elicitation_callback=handle_elicitation) as client: result = await client.call_tool("book_table", {"date": "Friday"}) ``` @@ -302,14 +314,14 @@ async def main() -> None: ハンドラーがリクエストの途中でクライアントに連絡を取ろうとしましたが、その接続の呼び出しには、サーバーからのリクエストを運べるチャネルがありません。呼び出しをそこに置くサーバー設定は 3 つあります。 -**`2026-07-28` の接続。どのトランスポートでも、常に。** モダンなプロトコルにはサーバー起点のリクエストがそもそも存在しないので、サーバーは何かを送る前に拒否します。ツールの中の `ctx.elicit()` が、これに出会う典型的な経路です(`Client(server)` は頼まれなくても `2026-07-28` をネゴシエートするので、最初のインメモリテストで出会います)。`elicitation_callback=` を渡しても何も変わりません。答えるべきリクエストがクライアントに届くことがないからです。 +**`2026-07-28` の接続。どのトランスポートでも、常に。** モダンなプロトコルにはサーバー起点のリクエストがそもそも存在しないので、サーバーは何かを送る前に拒否します。ツールの中の `ctx.elicit()` が、これに出会う典型的な経路です。たいていは、そのツールの最初のインメモリ**[テスト](get-started/testing.md)**で出会います。`Client(mcp)` は頼まれなくても `2026-07-28` をネゴシエートするからです。`elicitation_callback=` を渡しても何も変わりません。答えるべきリクエストがクライアントに届くことがないからです。 ```python title="server.py" hl_lines="16" --8<-- "docs_src/troubleshooting/tutorial006.py" ``` ```python -async def main() -> None: +async def test_book_table() -> None: async with Client(mcp) as client: await client.call_tool("book_table", {"date": "Friday"}) ``` @@ -347,7 +359,7 @@ mcp.shared.exceptions.MCPError: Cannot send 'elicitation/create': this transport ```python async def main() -> None: - async with Client(mcp) as client: + async with Client("http://localhost:8000/mcp") as client: await client.call_tool("forecast", {"city": "London"}, request_state="round-1-from-worker-a") ``` @@ -400,7 +412,7 @@ mcp = MCPServer("Weather", request_state_security=RequestStateSecurity(keys=[key * サーバーログの `Tool already exists:` は、同名の 2 つのツールが 1 つに潰れた唯一の合図です。 * 1 つの 421、3 つの綴り:`Server returned an error response`(python の `Client`)、`421 Misdirected Request` / `Invalid Host header`(それ以外すべて)、`Invalid Host header: `(サーバーログ)。直し方:`transport_security=TransportSecuritySettings(allowed_hosts=[...])`。 * `Task group is not initialized` -> マウントされたアプリで、ホストのライフスパンが `mcp.session_manager.run()` に入っていません。 -* `Session not found` -> サーバーが再起動しました。再接続してください。 +* `Session not found` -> サーバーが再起動したか、セッションが期限切れになりました(`session_idle_timeout`)。再接続してください。 * `Cannot send 'elicitation/create': ... no back-channel ...` -> `ctx.elicit()` にはサーバーからクライアントへのチャネルが必要です。`2026-07-28` の接続にはそれが決してなく、`stateless_http=True` はレガシーのチャネルを奪い、`json_response=True` はリクエストスコープのチャネルを奪います。リゾルバーを使ってください(レガシークライアントには、チャネルを保持するサーバーも必要です)。隣の `Method not found` は、相手側のプロトコルリビジョンにないメソッドへのリクエストです。 * `Client did not declare the form elicitation capability ...` と `Elicitation not supported` -> クライアントに `elicitation_callback=` が足りません。 * `Invalid or expired requestState` は、通信上では決して理由を言いません。サーバーログが言います。`unknown key` は、ワーカー間で `RequestStateSecurity(keys=[...])` を共有せよという意味です。 diff --git a/i18n/ja/pages/whats-new.md b/i18n/ja/pages/whats-new.md index eb226e26..71aacc66 100644 --- a/i18n/ja/pages/whats-new.md +++ b/i18n/ja/pages/whats-new.md @@ -1,6 +1,6 @@ --- translation: - sections: [cfe01c0c5863dfa2, 1c58c5cfcc37d455, a7392996acf1ad8f, 875eb2889263424e] + sections: [cfe01c0c5863dfa2, 0dbb68d8b210177b, 80cf193023af3ed4, 875eb2889263424e] tool: 1 --- # v2 の新機能 {#whats-new-in-v2} @@ -37,11 +37,11 @@ mcp = MCPServer("Demo") # v1: FastMCP("Demo") v1 では 3 つの層が入れ子になっていました。生のストリームを返すトランスポートのコンテキストマネージャー、それを包む `ClientSession`、そして手で呼び出す `await session.initialize()` です。v2 にあるのはオブジェクト 1 つです。 -```python title="client.py" hl_lines="14-18" ---8<-- "docs_src/client/tutorial001.py" +```python title="client.py" hl_lines="7-11" +--8<-- "docs_src/client/tutorial001_client.py" ``` -`Client` が受け取るのは、サーバーオブジェクト(インメモリでトランスポートなし。テストで使う形です)、URL(Streamable HTTP)、`StdioServerParameters`(stdio のサブプロセス)、または `sse_client(...)` のようなそれ以外の任意のトランスポートのコンテキストマネージャーです。`async with` に入ると接続し、サーバーがどの世代を話すかにかかわらずプロトコルバージョンをネゴシエートします。その後は `client.server_capabilities` と `client.protocol_version` がそのまま使え、サーバーが自身を名乗る場合は `client.server_info` も使えます(2026 年世代では識別情報が省略可能なので、`Implementation | None` になりました)。v1 で登録したサンプリングとエリシテーションのコールバックは引き続き動作します(コールバックの本体には、このページのほかの項目と同じ snake_case への属性名の変更が及びます)。加えて 2026 形式の「結果に埋め込まれたリクエスト」(後述)にも応答するようになり、1 つずつではなく並行して実行されます。低レベルのインターフェースが必要な人のために `ClientSession` は今も下にあり、`client.session` で取り出せます。ただしこちらも変わっています(新しいディスパッチャーエンジンの上で動き、自身のシグネチャも一部変わりました)。下りていく前に**[移行ガイド](migration.md#clientsession-now-runs-on-jsonrpcdispatcher-basesession-removed)**を読んでください。 +`Client` が受け取るのは、URL(Streamable HTTP)、`StdioServerParameters`(stdio のサブプロセス)、`sse_client(...)` のようなそれ以外の任意のトランスポートのコンテキストマネージャー、またはテストで使うサーバーオブジェクトそのもの(インメモリでトランスポートなし)です。`async with` に入ると接続し、サーバーがどの世代を話すかにかかわらずプロトコルバージョンをネゴシエートします。その後は `client.server_capabilities` と `client.protocol_version` がそのまま使え、サーバーが自身を名乗る場合は `client.server_info` も使えます(2026 年世代では識別情報が省略可能なので、`Implementation | None` になりました)。v1 で登録したサンプリングとエリシテーションのコールバックは引き続き動作します(コールバックの本体には、このページのほかの項目と同じ snake_case への属性名の変更が及びます)。加えて 2026 形式の「結果に埋め込まれたリクエスト」(後述)にも応答するようになり、1 つずつではなく並行して実行されます。低レベルのインターフェースが必要な人のために `ClientSession` は今も下にあり、`client.session` で取り出せます。ただしこちらも変わっています(新しいディスパッチャーエンジンの上で動き、自身のシグネチャも一部変わりました)。下りていく前に**[移行ガイド](migration.md#clientsession-now-runs-on-jsonrpcdispatcher-basesession-removed)**を読んでください。 **[Client](client/index.md)** で紹介し、**[クライアントのトランスポート](client/transports.md)**で 4 つの接続形態を、**[クライアントのコールバック](client/callbacks.md)**でコールバックそのものを扱います。**[テスト](get-started/testing.md)**では、v1 の `create_connected_server_and_client_session()` ヘルパーに代わるインメモリのパターンを示します。 @@ -166,11 +166,15 @@ Streamable HTTP では、2026 の経路に `Mcp-Session-Id` がありません 代わりの仕組みは呼び出しの向きを逆にします。ユーザーから何かを必要とするツールは質問を「返し」(`InputRequiredResult`)、クライアントはこれまでと同じコールバックでそれに答え、答えを添えて呼び出しが再試行されます。そのループは `Client` が回します。サーバー側で結果を自分で組み立てることはめったにありません。**[依存関係](handlers/dependencies.md)**がやってくれるからです。パラメーターを `Resolve(ask_quantity)` で注釈します(`ask_quantity` は自分で書く普通の関数です)。すると SDK は接続が対応している仕組み、つまりレガシーセッションならその場で送るエリシテーションリクエスト、2026 ならマルチラウンドトリップで質問します。ツール本体は 1 つ、世代は両方です。 -```python title="dual_era.py" hl_lines="24 37-38" +```python title="server.py" hl_lines="21" --8<-- "docs_src/legacy_clients/tutorial001.py" ``` -このファイル 1 つに要点が詰まっています。1 つのサーバー、`Resolve` に支えられた 1 つのツール、そしてレガシークライアントと新世代のクライアントの両方がインメモリで答えを受け取ります。仕組み(SDK が封印と検証を行う `request_state` を含む)は**[マルチラウンドトリップリクエスト](handlers/multi-round-trip.md)**が説明し、質問のしかたは**[エリシテーション](handlers/elicitation.md)**が扱います。 +```python title="client.py" hl_lines="14-15" +--8<-- "docs_src/legacy_clients/tutorial001_client.py" +``` + +この 2 つのファイルに要点が詰まっています。1 つのサーバー、`Resolve` に支えられた 1 つのツール、そしてレガシークライアントと新世代のクライアントの両方が、同じ実行中のサーバーから答えを受け取ります(**[レガシークライアントへの対応](run/legacy-clients.md)**がこの 2 つのファイルを順にたどります)。仕組み(SDK が封印と検証を行う `request_state` を含む)は**[マルチラウンドトリップリクエスト](handlers/multi-round-trip.md)**が説明し、質問のしかたは**[エリシテーション](handlers/elicitation.md)**が扱います。 !!! warning "移植した v1 サーバーの動作が変わる唯一の場所" 最初にぶつかるのは自分のテストです。`Client(mcp)` はデフォルトで v2 サーバーに対して 2026-07-28 をネゴシエートするので、`ctx.elicit()` を呼ぶツールは v1 で通っていたテストで失敗します。質問を `Resolve(...)` パラメーターに移す(世代をまたいで使えます)か、本当にプッシュ型の動作が欲しいならテストクライアントを `mode="legacy"` に固定してください。 diff --git a/i18n/ko/pages/advanced/apps.md b/i18n/ko/pages/advanced/apps.md index bb040b9d..2b10642a 100644 --- a/i18n/ko/pages/advanced/apps.md +++ b/i18n/ko/pages/advanced/apps.md @@ -1,6 +1,6 @@ --- translation: - sections: [0355618e5f4d5fe4, 1821eaf50f2d0b64, 82e0b28ebd3abf5a, 8ac39614c094f2d0, dab6ff945501ab2a, bd5565c3b2d4f959, 96819ce3d63a0487] + sections: [0355618e5f4d5fe4, 0fefa31fb7c7b585, 5c53e7487c9c70cc, 8ac39614c094f2d0, dab6ff945501ab2a, bd5565c3b2d4f959, 96819ce3d63a0487] tool: 1 --- # MCP Apps {#mcp-apps} @@ -18,7 +18,7 @@ SDK는 이를 내장 `Apps` 확장(`io.modelcontextprotocol/ui`)으로 제공합 ## 얼굴을 가진 시계 {#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" ``` @@ -39,11 +39,31 @@ HTML 자체는 호스트의 `postMessage`를 수신하고 결과를 표시합니 모델은 `content`를 읽고, iframe은 사람을 위한 것입니다. UI를 지원하는 호스트도 여전히 텍스트 결과를 모델에 전달하며, 텍스트 전용 클라이언트는 **오직** 그 텍스트만 받습니다. 따라서 표준 패턴은 도구 하나에 답 둘입니다. `get_time`을 다시 살펴보세요. -```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)`는 클라이언트가 `io.modelcontextprotocol/ui` 확장을 선언했고 **동시에** `mimeTypes` 설정에 `text/html;profile=mcp-app`을 나열했을 때만 `True`입니다. 이 필드는 필수이므로 생략한 클라이언트는 해당하지 않습니다. 같은 파일의 `main()`이 선언하는 것이 바로 이것입니다. 협상의 클라이언트 쪽 절반을 선언하면 풍부한 답이 돌아옵니다. +`client_supports_apps(ctx)`는 클라이언트가 `io.modelcontextprotocol/ui` 확장을 선언했고 **동시에** `mimeTypes` 설정에 `text/html;profile=mcp-app`을 나열했을 때만 `True`입니다. 이 필드는 필수이므로 생략한 클라이언트는 해당하지 않습니다. 협상의 클라이언트 쪽 절반은 다음과 같습니다. + +```python title="client.py" hl_lines="8 12" +--8<-- "docs_src/apps/tutorial001_client.py" +``` + +`server.py`를 HTTP로 제공한 다음, 두 번째 터미널에서 클라이언트를 실행하세요. + +```console +uv run mcp run server.py --transport streamable-http +``` + +```console +python client.py +``` + +```text +2026-06-26T12:00:00Z +``` + +풍부한 답이 돌아왔습니다. `Client` 호출에서 `extensions=[APPS_SUPPORT]`를 빼면 같은 프로그램이 대신 `The time is 2026-06-26T12:00:00Z.`라고 출력하며, 텍스트 전용 클라이언트가 보게 되는 것은 언제나 이것뿐입니다. !!! warning `"[Rendered UI]"` 같은 자리 표시자를 유일한 content로 반환하지 마세요. 대체 텍스트가 쓸모없다면, 그 도구는 모든 텍스트 전용 클라이언트와 모델 자체에 쓸모없는 도구가 됩니다. 제대로 된 문장을 작성하세요. diff --git a/i18n/ko/pages/advanced/extensions.md b/i18n/ko/pages/advanced/extensions.md index a269ffc2..6a26bcde 100644 --- a/i18n/ko/pages/advanced/extensions.md +++ b/i18n/ko/pages/advanced/extensions.md @@ -1,6 +1,6 @@ --- translation: - sections: [05891e7cc1938a13, b3c01a6af28c51ee, 7ffc91f5e38bdfe0, 717d3f235a8333a7, f471a13b2fe5d737, ed6af2df4b656dff] + sections: [05891e7cc1938a13, b3c01a6af28c51ee, 6eba6ce094f4417e, 125f28588c85dd7b, ddd8969b698ca11c, ed6af2df4b656dff] tool: 1 --- # 확장 {#extensions} @@ -49,7 +49,7 @@ TypeError: Stamps.identifier must be a `vendor-prefix/name` string 쓸모 있는 가장 작은 확장은 도구 하나와 설정 맵 하나입니다. -```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" ``` @@ -57,17 +57,23 @@ TypeError: Stamps.identifier must be a `vendor-prefix/name` string * `settings()`는 `capabilities.extensions["com.example/stamps"]`에 광고되는 값입니다. 설정 없이 확장을 광고하려면 `{}`(기본값)을 반환하세요. * 확장은 서버를 절대 전달받지 않습니다. 기여할 내용을 데이터로 선언하고, `MCPServer`가 이를 소비합니다. 변경할 `self.server` 같은 것은 없습니다. -그리고 `main()`이 그 증거입니다. `mcp`에 바로 연결하는 인메모리 클라이언트입니다. +HTTP로 서비스하면, 클라이언트가 곧 그 증거가 됩니다. -```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" +``` + +이 페이지의 모든 `server.py`는 이 명령으로 서비스하고, 모든 `client.py`는 두 번째 터미널에서 `python client.py`로 그 옆에서 실행합니다. + ### 자체 메서드 제공하기 {#serving-your-own-methods} 확장은 **새로운 요청 메서드**를 등록할 수 있습니다. 사양의 동사 옆에서 함께 서비스되는 자체 동사입니다. -```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" ``` @@ -83,14 +89,15 @@ TypeError: Stamps.identifier must be a `vendor-prefix/name` string ### 클라이언트 측 {#the-client-side} -같은 파일의 `main()`이 클라이언트 쪽 이야기의 전부이며, 두 부분을 모두 담고 있습니다. +클라이언트는 독립된 프로그램이며, 클라이언트 쪽 이야기의 두 부분을 모두 담고 있습니다. -```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)])`가 확장을 선언합니다. 선언은 `ClientCapabilities.extensions`가 됩니다. 2026-07-28 연결에서는 이 맵이 요청별 `_meta` 봉투에 실려 이동하므로 서버는 **모든** 요청에서 이를 봅니다. 레거시 연결에서는 `initialize` 핸드셰이크에 실립니다. 서버 코드는 어느 쪽인지 신경 쓰지 않습니다. `require_client_extension(ctx, ...)`와 `ctx.session.check_client_capability(...)`는 두 경로 모두에서 올바른 출처를 읽습니다. * 벤더 메서드는 한 계층 아래인 `client.session.send_request(...)`로 내려갑니다. `Client`는 사양 동사에 대해서만 일급 메서드를 갖춥니다. `send_request`는 모든 `Request` 서브클래스를 받으므로 벤더 요청은 그대로 통과합니다. +* `SearchRequest`와 여기에 실리는 두 모델은 확장의 와이어 계약이므로 클라이언트가 직접 선언합니다. 공개 배포되는 확장이라면 양쪽이 함께 임포트하는 패키지에 담아 제공합니다. ### `tools/call` 가로채기 {#intercepting-toolscall} @@ -109,12 +116,18 @@ TypeError: Stamps.identifier must be a `vendor-prefix/name` string ## 클라이언트 확장 사용하기 {#using-a-client-extension} -**클라이언트 확장**은 소비하는 쪽에서 본 같은 계약으로, 하나의 식별자 아래 묶인 클라이언트 측 동작의 묶음입니다. `Client(extensions=[...])`에 인스턴스를 전달하고 평소처럼 도구를 호출하세요. +**클라이언트 확장**은 소비하는 쪽에서 본 같은 계약으로, 하나의 식별자 아래 묶인 클라이언트 측 동작의 묶음입니다. 여기서 서버는 `buy`에 상품 대신 정산할 영수증으로 응답하며, 그것도 확장을 선언한 클라이언트에게만 그렇게 합니다. -```python title="client.py" hl_lines="66-68" +```python title="server.py" hl_lines="22-25" --8<-- "docs_src/extensions/tutorial006.py" ``` +클라이언트에서는 `Client(extensions=[...])`에 인스턴스를 전달하고 평소처럼 도구를 호출하세요. + +```python title="client.py" hl_lines="33-35" +--8<-- "docs_src/extensions/tutorial006_client.py" +``` + `call_tool("buy", ...)`은 다른 모든 호출처럼 평범한 `CallToolResult`를 반환합니다. 확장이 바꾼 것은 이렇습니다. 이제 서버는 `buy`에 최종 결과 대신 `receipt` **결과 형태**로 응답할 수 있고, `call_tool`이 반환하기 전에 `Receipts`가 이를 마무리합니다(여기서는 후속 호출로 영수증을 정산합니다). 호출 지점에서는 아무것도 달라지지 않습니다. 확장을 빼면 이 중 어떤 것도 존재하지 않습니다. 서버의 관문은 확장을 선언하지 않은 클라이언트를 거부하고(오류 -32021), 관문을 건너뛰는 서버가 보낸 클레임된 형태는 인식되지 않은 `resultType`에 대해 사양이 요구하는 그대로 검증에 실패합니다. 와이어 양 끝 모두에서 기본적으로 꺼져 있습니다. @@ -124,15 +137,15 @@ TypeError: Stamps.identifier must be a `vendor-prefix/name` string ```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} `ClientExtension`을 서브클래싱하고 필요한 것만 재정의하세요. 기여 종류는 세 가지이며 각각 기본 구현이 있습니다. `settings()`, `claims()`, `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" ``` * 식별자는 서버의 것과 같은 문법을 따르며 클래스가 정의될 때 검증됩니다. @@ -153,12 +166,18 @@ def notifications(self) -> Sequence[NotificationBinding[Any]]: ### 확장 동사 {#extension-verbs} -확장의 자체 요청 메서드에는 클라이언트 측 등록이 필요 없습니다. 벤더 요청 타입은 `mcp.types.Request`를 서브클래싱하고, [자체 메서드 제공하기](#serving-your-own-methods)에서처럼 `client.session.send_request`를 거칩니다. 한 가지가 더 있습니다. params 키가 `Mcp-Name` 헤더에 실려야 할 때(tasks 같은 확장 사양은 자신의 동사에 이를 요구합니다) 요청 타입이 `name_param`을 선언합니다. +확장의 자체 요청 메서드에는 클라이언트 측 등록이 필요 없습니다. 벤더 요청 타입은 `mcp.types.Request`를 서브클래싱하고, [자체 메서드 제공하기](#serving-your-own-methods)에서처럼 `client.session.send_request`를 거칩니다. 확장이 이름 붙은 작업에 관한 동사 하나를 서비스하는 서버를 예로 들어 보겠습니다. -```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" ``` +클라이언트 쪽에는 한 가지가 추가됩니다. params 키가 `Mcp-Name` 헤더에 실려야 할 때(tasks 같은 확장 사양은 자신의 동사에 이를 요구합니다) 요청 타입이 `name_param`을 선언합니다. + +```python title="client.py" hl_lines="20-23 28-29" +--8<-- "docs_src/extensions/tutorial007_client.py" +``` + 세션은 모든 전송 경로에서 `params["jobId"]`를 `Mcp-Name`에 반영하며, 값이 없으면 필수 헤더를 조용히 빠뜨리는 대신 명시적으로 실패합니다. ## 확장이 할 수 없는 것 {#what-an-extension-cannot-do} diff --git a/i18n/ko/pages/advanced/low-level-server.md b/i18n/ko/pages/advanced/low-level-server.md index 811989ad..218397f0 100644 --- a/i18n/ko/pages/advanced/low-level-server.md +++ b/i18n/ko/pages/advanced/low-level-server.md @@ -1,6 +1,6 @@ --- translation: - sections: [2c79b6338e09b7ac, 7edc43b3fae11314, 1086e77ce561cd7f, a3f71823df5efc31, 9fc7109f72201cae, d50fe7faead8cf68, 7bf25983df655b66, 6330e1f4c6029683, 2f1749c8c133fa1c, 8db7116fc8ddd0ee, ebc33704fbd74262, cd0e9c933350390e] + sections: [2c79b6338e09b7ac, 9d5d10a5f0405d0a, 1086e77ce561cd7f, a3f71823df5efc31, 9fc7109f72201cae, d50fe7faead8cf68, 7bf25983df655b66, 6330e1f4c6029683, 2f1749c8c133fa1c, 8db7116fc8ddd0ee, ebc33704fbd74262, 0fde3bcea081ba3a] tool: 1 --- # 저수준 Server {#the-low-level-server} @@ -36,18 +36,22 @@ translation: ### 직접 해 보기 {#try-it} -이번에는 Inspector를 쓸 수 없습니다. `mcp dev`와 `mcp run`은 `MCPServer`만 받습니다. 인메모리 `Client`는 상관하지 않으며, `MCPServer`를 받는 것과 똑같이 저수준 `Server`도 받습니다. +`mcp dev`와 `mcp run`은 `MCPServer`만 받으므로 이 서버는 직접 띄워야 합니다. `server.py`의 마지막 줄이 이 서버로 평범한 ASGI 앱을 만들고, uvicorn이 그 앱을 실행합니다. -```python title="main.py" +```console +uvicorn server:app --port 8000 +``` + +Inspector든 다른 어떤 클라이언트든 `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) @@ -64,6 +68,8 @@ asyncio.run(main()) * `result.structured_content`가 `None`입니다. 고수준 서버는 `-> str` 반환값을 `{"result": ...}`로 감싸 주지만, 여기서는 직접 만들지 않은 것을 대신 만들어 주는 곳이 없습니다. * `list_tools`는 **직접** 입력한 스키마를 글자 하나까지 그대로 반환합니다. 고수준 버전에는 모든 속성에 `"title": "Query"`가, 루트에 `"title": "search_booksArguments"`가 있었습니다. Pydantic이 남긴 흔적입니다. 여기서는 와이어에 실린 것이라면 전부 직접 넣은 것입니다. +테스트에서는 uvicorn과 포트를 건너뜁니다. `Client(server)`는 `MCPServer`를 받는 것과 똑같이 저수준 `Server`도 프로세스 안에서 받으며, **[테스트](../get-started/testing.md)**가 바로 그 패턴입니다. + ## 자동 검증 없음 {#nothing-is-checked-for-you} `MCPServer`는 함수가 실행되기도 전에, 생성한 스키마에 호출을 대조해 검증하여 잘못된 인수를 거부합니다(**[도구](../servers/tools.md)**). @@ -215,4 +221,4 @@ use Server.middleware to observe or wrap initialization * `add_request_handler(method, params_type, handler)`는 어떤 메서드든 제공합니다. `initialize`는 예약되어 있습니다. * `Server`가 알리는 기능은 등록한 핸들러에서 도출됩니다. -`Client(server)`가 두 서버를 똑같이 다룬 것은 둘이 **같은** 프로토콜이기 때문이며, 바로 그 점이 핵심입니다. 그다음 아래 계층은 클래스가 아닙니다. 바로 **[미들웨어](middleware.md)**입니다. +클라이언트가 두 서버를 똑같이 다룬 것은 둘이 **같은** 프로토콜이기 때문이며, 바로 그 점이 핵심입니다. 그다음 아래 계층은 클래스가 아닙니다. 바로 **[미들웨어](middleware.md)**입니다. diff --git a/i18n/ko/pages/advanced/pagination.md b/i18n/ko/pages/advanced/pagination.md index def248d8..9d5ff400 100644 --- a/i18n/ko/pages/advanced/pagination.md +++ b/i18n/ko/pages/advanced/pagination.md @@ -1,6 +1,6 @@ --- translation: - sections: [a9aba7a026c7bd85, ed32bda7ba9ae33a, 7e64cc5646abb91f, 22a0129ee78b3c63, d875373c06d8d2f9] + sections: [a9aba7a026c7bd85, 83e2a08b9d46a398, 9fd8a0aa384b3257, 22a0129ee78b3c63, d875373c06d8d2f9] tool: 1 --- # 페이지네이션 {#pagination} @@ -31,9 +31,13 @@ translation: ### 직접 해 보기 {#try-it} -`Client(server)`는 `MCPServer`에 연결할 때와 똑같이 저수준 `Server`에 인메모리로 연결합니다. +`mcp run`은 `MCPServer`만 받으므로 이 서버는 직접 띄워야 합니다. `server.py`의 마지막 줄이 `Server`로 평범한 ASGI 앱을 만들고, uvicorn이 그 앱을 실행합니다. -인자 없이 `list_resources()`를 호출하세요. `book-1`부터 `book-10`까지 리소스 10개가 돌아오고, `next_cursor`는 문자열 `"10"`입니다. +```console +uvicorn server:app --port 8000 +``` + +아무 클라이언트(**[클라이언트](../client/index.md)** 또는 Inspector)나 `http://localhost:8000/mcp`에 연결하고 인자 없이 `list_resources()`를 호출하세요. `book-1`부터 `book-10`까지 리소스 10개가 돌아오고, `next_cursor`는 문자열 `"10"`입니다. 이를 `list_resources(cursor="10")`으로 다시 넘기면 첫 번째 리소스는 `book-11`이고, 새 `next_cursor`는 `"20"`입니다. @@ -43,7 +47,7 @@ translation: `Client`의 모든 `list_*` 메서드(`list_tools`, `list_resources`, `list_resource_templates`, `list_prompts`)는 `cursor=` 키워드를 받습니다. 페이지로 나뉜 목록을 전부 가져오는 것은 `while True` 하나면 됩니다. -```python title="client.py" hl_lines="26-32" +```python title="client.py" hl_lines="9-15" --8<-- "docs_src/pagination/tutorial002.py" ``` @@ -51,7 +55,7 @@ translation: * `next_cursor`를 확인하기 **전에** 결과를 덧붙이세요. 마지막 페이지에도 리소스가 있습니다. * `next_cursor is None`이 종료 조건입니다. 그 외의 값은 손대지 않고 그대로 `cursor=`에 다시 넣습니다. -이 파일의 `main()`을 실행하면 `100 resources`가 출력됩니다. 열 개씩 열 페이지가, 페이지가 열 개라는 사실조차 모르는 루프에 의해 하나로 이어 붙여진 결과입니다. +uvicorn이 `server.py`를 계속 띄워 둔 상태에서 두 번째 터미널에서 `python client.py`를 실행하세요. `100 resources`가 출력됩니다. 열 개씩 열 페이지가, 페이지가 열 개라는 사실조차 모르는 루프를 통해 하나로 이어 붙여진 결과입니다. 이 루프는 **[클라이언트](../client/index.md)**에서 모든 `list_*` 동사에 대해 보여 주는 것과 같은 루프이며, 페이지를 나누지 않는 서버에 대해서도 비용이 들지 않습니다. 첫 응답에서 `next_cursor`가 `None`이므로 루프는 한 번만 돕니다. diff --git a/i18n/ko/pages/client/caching.md b/i18n/ko/pages/client/caching.md index 026272c2..50625fa1 100644 --- a/i18n/ko/pages/client/caching.md +++ b/i18n/ko/pages/client/caching.md @@ -1,6 +1,6 @@ --- translation: - sections: [9e7b9a1710e5aeba, b74ca4c1d2ddddee, fa8714e61bf90c5a, 04db67a886b7271c, 857690fb8f876800] + sections: [9e7b9a1710e5aeba, 66a2e9acc9101d54, d3d25aa802f5145a, 04db67a886b7271c, 857690fb8f876800] tool: 1 --- # 캐싱 힌트 {#caching-hints} @@ -30,7 +30,7 @@ translation: 저수준 `Server`에서는 핸들러가 결과를 직접 조립하며, `ttl_ms` / `cache_scope`는 결과 모델의 필드일 뿐입니다. 이 필드를 명시적으로 설정한 핸들러는 필드 단위로 언제나 생성자 맵보다 우선합니다. -```python title="server.py" hl_lines="10 16" +```python title="server.py" hl_lines="11 17" --8<-- "docs_src/caching/tutorial002.py" ``` @@ -44,10 +44,24 @@ translation: 2026-07-28 세션에서는 `Client`가 힌트를 대신 따릅니다. 기본으로 켜져 있는 내장 응답 캐시가 있기 때문입니다. `ttlMs`를 싣고 도착한 결과는 저장되고, 그 TTL 안에 동일한 호출이 오면 왕복 없이 캐시에서 제공됩니다. 힌트가 **없는** 결과는 캐시되지 않습니다. 힌트 없는 결과에는 `CacheConfig.default_ttl_ms`가 적용되는데 기본값이 `0`(즉시 만료)이므로, 아무것도 선언하지 않는 서버는 늘 그랬듯 호출마다 요청이 오는 트래픽을 그대로 보게 됩니다. -```python title="client.py" hl_lines="33 35 38" +이 동작을 직접 확인하려면 앞 절의 `server.py`를 uvicorn으로 띄우세요(마지막 줄이 ASGI 앱을 만듭니다). 핸들러는 실제로 실행될 때마다 한 줄을 출력합니다. + +```console +uvicorn server:app --port 8000 +``` + +```python title="client.py" hl_lines="20 23 28" --8<-- "docs_src/caching/tutorial003.py" ``` +두 번째 터미널에서 `python client.py`를 실행하세요. 첫 번째 결과가 싣고 온 힌트, 즉 핸들러의 `ttlMs`와 맵의 `cacheScope`를 나란히 출력합니다. + +```text +1000 public +``` + +나머지는 서버 쪽 터미널이 알려 줍니다. uvicorn의 요청 로그 사이로 `tools/list served`가 세 번 나타납니다. + 호출은 네 번, 서버에서 가져온 것은 세 번입니다. 두 번째 호출은 신선한 항목을 찾았고 서버에 도달하지 않았습니다. (주입된) 시계를 TTL 너머로 진행시키자 세 번째 호출은 다시 가져왔고, 네 번째 호출은 `cache_mode="refresh"`를 지정했습니다. 이 키워드 인자는 캐싱 동사 다섯 개(`list_tools`, `list_prompts`, `list_resources`, `list_resource_templates`, `read_resource`)에 있습니다. * `"use"`(기본값)는 신선한 항목이 있으면 해당 항목을 제공하고, 없으면 가져와서 저장합니다. @@ -56,7 +70,7 @@ translation: `"use"` 위에 규칙이 하나 더 있습니다. **`meta`를 담은 호출은 언제나 서버에 도달합니다.** `meta`가 설정된 요청(진행률 토큰, 추적 필드)은 실제로 전송되는 요청을 기대하므로, `cache_mode="use"`에서는 `"refresh"`로 취급됩니다. 캐시 읽기는 건너뛰고, 가져온 결과는 여전히 캐시된 항목을 대체합니다. `"bypass"`와 명시적 `"refresh"`는 평소대로 동작합니다. -캐싱을 완전히 끄려면 `Client(server, cache=None)`으로 생성하세요. 모든 호출이 다시 왕복이 되며, `cache_mode`는 여전히 받아들여지지만 아무 일도 하지 않습니다. +캐싱을 완전히 끄려면 `Client`를 생성할 때 `cache=None`을 전달하세요. 모든 호출이 다시 왕복이 되며, `cache_mode`는 여전히 받아들여지지만 아무 일도 하지 않습니다. 범위도 자동으로 존중됩니다. `"private"` 항목은 캐시의 **파티션**(아래 참고)을 키로 하고, `"public"` 항목은 더 넓은 공유를 선택할 수 있습니다. 그리고 알림이 지목하는 바로 그 항목에 대해서는 **알림이 TTL보다 우선합니다**. `list_changed` 알림은 일치하는 캐시된 목록을 축출하고, `resources/updated`는 정확히 그 URI로 저장된 캐시된 읽기 결과를 축출합니다. 얼마나 신선했든 상관없습니다. 2026-07-28 연결에서 이 알림은 `client.listen(...)`으로 여는 `subscriptions/listen` 스트림으로 도착하며, 축출은 감시자가 이벤트를 보기 전에 완료됩니다. 자세한 내용은 **[구독](subscriptions.md)**에서 확인하세요. diff --git a/i18n/ko/pages/client/callbacks.md b/i18n/ko/pages/client/callbacks.md index 49bbd347..f9b023a7 100644 --- a/i18n/ko/pages/client/callbacks.md +++ b/i18n/ko/pages/client/callbacks.md @@ -1,6 +1,6 @@ --- translation: - sections: [adf3c545b5be46b6, 916cd3ab1c03f461, e9be7a8d0eb0a456, 565890a636288ecf, 6af7e49db9129ec3, 06b0238c174186af, 90c6043be435fcb0] + sections: [adf3c545b5be46b6, 916cd3ab1c03f461, 32ef568335dd95a7, 565890a636288ecf, 6af7e49db9129ec3, 06b0238c174186af, 0abc5ea5cb7ff6b3] tool: 1 --- # 클라이언트 콜백 {#client-callbacks} @@ -63,7 +63,7 @@ result.content # [TextContent(type='text', text='Card issued to Ada Lovelace.') `Client(...)` 호출의 `mode="legacy"`는 실제로 중요한 역할을 합니다. 기본적으로 `Client(...)`는 최신 프로토콜 경로를 협상하는데, 그 경로에는 서버에서 클라이언트로 가는 요청을 위한 역방향 채널이 없어서 콜백이 실행되기도 전에 `ctx.elicit` 호출이 실패합니다. 이를 결정하는 것은 트랜스포트가 아니라 협상된 - 프로토콜이며, 인메모리든 URL을 통하든 마찬가지입니다. 클라이언트가 이런 요청에 응답해야 할 때마다 + 프로토콜입니다. 클라이언트가 이런 요청에 응답해야 할 때마다 `mode="legacy"`로 고정하세요. 이 페이지를 뒷받침하는 모든 테스트가 그렇게 합니다. 자세한 내용은 **[프로토콜 버전](../protocol-versions.md)**에서 확인하세요. 2026-07-28 세션에서도 콜백이 쓸모없어지는 것은 아니며, 입력을 받는 방식이 다를 뿐입니다. 도구가 @@ -151,4 +151,4 @@ result.structured_content # {'result': ['elicitation']} * `sampling_callback`과 `list_roots_callback`도 같은 방식으로 동작하지만 지원 중단 예정 기능을 처리합니다. 최신 서버는 대신 다중 왕복 요청을 사용합니다. * `logging_callback`과 `message_handler`는 알림을 받습니다. 아무것도 선언하지 않습니다. -`Client(...)`의 첫 번째 인자는 트랜스포트 객체입니다. 모든 종류는 **[클라이언트 트랜스포트](transports.md)**에서 다룹니다. +`Client(...)`의 첫 번째 인자가 트랜스포트를 결정합니다. 모든 종류는 **[클라이언트 트랜스포트](transports.md)**에서 다룹니다. diff --git a/i18n/ko/pages/client/index.md b/i18n/ko/pages/client/index.md index 636b1609..51c1e151 100644 --- a/i18n/ko/pages/client/index.md +++ b/i18n/ko/pages/client/index.md @@ -1,6 +1,6 @@ --- translation: - sections: [ebef1e7a0df854f4, 8355cfaf1f76c9d5, 8e79141fc2985342, 46bdb07c7537e8a5, 80ce41579825a6fa, 5f0fa90494de8f65, 83d10514eaa62fa5, 9190555aa39a5d28, 84a4c9d8bf14dddb, 927d71cf40b58c30] + sections: [ebef1e7a0df854f4, 7e16449f66e7dfd6, eeb0682f7d2a1079, 5713f0196a34e6e7, 0e844597859e4248, 3a97d9195ddcc92e, 1da08c483e59c141, 84702cc6e0a1fd42, 8dee7a31c86ffc5c, 83a5bce168ef23d7] tool: 1 --- # 클라이언트 {#the-client} @@ -11,13 +11,23 @@ translation: ## 첫 번째 클라이언트 {#your-first-client} -```python title="client.py" hl_lines="14-18" +클라이언트에게는 통신할 서버가 필요합니다. 이 페이지의 모든 예제가 연결하는 대상이 바로 이 Bookshop 서버입니다. `server.py`로 저장하고 HTTP로 실행해 두세요. + +```python title="server.py" --8<-- "docs_src/client/tutorial001.py" ``` -맨 위의 서버는 연결할 대상을 마련하기 위해 있을 뿐입니다. 클라이언트는 강조 표시된 다섯 줄입니다. +```console +uv run mcp run server.py --transport streamable-http +``` -* `Client(mcp)`에는 **서버 객체 자체**를 넘깁니다. 이것이 인메모리 트랜스포트입니다. 서브프로세스도, 포트도, HTTP도 없습니다. 이 페이지의 모든 예제와 앞으로 작성할 모든 테스트가 이 방식으로 연결합니다. +이렇게 하면 `http://localhost:8000/mcp`에서 서버가 제공됩니다. 클라이언트는 별도의 프로그램입니다. `client.py`로 저장하고 두 번째 터미널에서 `python client.py`를 실행하세요. + +```python title="client.py" hl_lines="7-11" +--8<-- "docs_src/client/tutorial001_client.py" +``` + +* `Client("http://localhost:8000/mcp")`에는 **URL**을 넘기므로, 방금 시작한 서버에 Streamable HTTP로 연결합니다. * `async with`가 **생명 주기**입니다. 들어가면 연결하고 협상하며, 나오면 연결을 끊습니다. `connect()` / `close()` 쌍은 없으며, 블록이 끝난 뒤에는 `Client`를 재사용할 수 없습니다. * 블록 안에서는 연결 정보가 이미 평범한 프로퍼티로 준비되어 있습니다. @@ -25,10 +35,10 @@ translation: `Client`는 위치 인자 하나를 받고, 그 타입으로 트랜스포트를 결정합니다. -* `MCPServer`(또는 저수준 `Server`) 인스턴스: **프로세스 내부**에서 연결합니다. -* URL 문자열(`Client("http://localhost:8000/mcp")`): 프로덕션 경로인 Streamable HTTP입니다. -* `StdioServerParameters`: **서브프로세스**로 실행할 명령이며, 그 stdin과 stdout을 통해 통신합니다. +* URL 문자열(`Client("http://localhost:8000/mcp")`): 실제 배포에 쓰는 트랜스포트인 Streamable HTTP입니다. +* `StdioServerParameters`: 로컬 **서브프로세스**로 실행할 명령이며, 그 stdin과 stdout을 통해 통신합니다. * **트랜스포트**: `async with ... as (read, write)`로 사용할 수 있는 모든 것, 예를 들어 직접 만든 HTTP 클라이언트를 감싸는 `streamable_http_client(url, http_client=...)`입니다. +* `MCPServer`(또는 저수준 `Server`) 인스턴스: 서브프로세스도 포트도 없이 **프로세스 내부**에서 연결합니다. 이 방식은 테스트를 위한 것이며, **[테스트](../get-started/testing.md)**가 이를 기반으로 합니다. 이 페이지의 나머지 내용은 네 가지 모두에서 동일합니다. 헤더, 서브프로세스, 타임아웃, `Transport` 프로토콜은 별도의 페이지인 **[클라이언트 트랜스포트](transports.md)**에서 다룹니다. @@ -49,11 +59,11 @@ translation: ## 도구 목록 조회 {#listing-tools} -```python title="client.py" hl_lines="15-20" +```python title="client.py" hl_lines="8-13" --8<-- "docs_src/client/tutorial002.py" ``` -`list_tools()`는 `ListToolsResult`를 반환하며, 도구는 `.tools`에 들어 있습니다. 각 도구는 호스트가 모델에 건네는 완전한 정의입니다. +`list_tools()`는 `ListToolsResult`를 반환하며, 도구는 `.tools`에 들어 있습니다. 각 도구는 호스트가 모델에 건네는 완전한 정의입니다. 첫 번째 도구는 다음과 같습니다. ```python tool.name # 'search_books' @@ -77,6 +87,8 @@ tool.description # 'Search the catalog by title or author.' 이 스키마는 UI가 인자 입력 폼을 렌더링하는 데 필요한 전부이자, 모델이 유효한 인자를 만들어 내는 데 필요한 전부입니다. +두 번째 도구인 `lookup_book`은 `title=` 없이 등록되었으므로 `tool.title`이 `None`입니다. + !!! tip `title`은 선택 사항이므로, 사람에게 도구를 보여 주는 UI는 무엇을 표시할지 골라야 합니다. `title`이 있으면 쓰고, 없으면 `name`을 씁니다. `from mcp.shared.metadata_utils import get_display_name`이 정확히 그 일을 하며, @@ -86,7 +98,7 @@ tool.description # 'Search the catalog by title or author.' `call_tool(name, arguments)`는 도구를 실행하고 `CallToolResult`를 돌려줍니다. -```python title="client.py" hl_lines="27-34" +```python title="client.py" hl_lines="9-16" --8<-- "docs_src/client/tutorial003.py" ``` @@ -142,7 +154,7 @@ result.is_error # False 리소스 동작은 짝을 이룹니다. 목록을 조회하는 방법이 둘, 읽는 방법이 하나입니다. -```python title="client.py" hl_lines="22-31" +```python title="client.py" hl_lines="9-18" --8<-- "docs_src/client/tutorial004.py" ``` @@ -156,7 +168,7 @@ result.is_error # False ## 프롬프트 {#prompts} -```python title="client.py" hl_lines="15-20" +```python title="client.py" hl_lines="8-13" --8<-- "docs_src/client/tutorial005.py" ``` @@ -181,7 +193,7 @@ message.content # TextContent(type='text', text='Recommend one poetry book from 자동 완성 핸들러가 있는 서버는 사용자가 입력하는 동안 프롬프트와 리소스 템플릿 인자를 자동 완성할 수 있습니다. -```python title="client.py" hl_lines="27-31" +```python title="client.py" hl_lines="9-13" --8<-- "docs_src/client/tutorial006.py" ``` @@ -194,21 +206,21 @@ message.content # TextContent(type='text', text='Recommend one poetry book from 모든 `list_*` 메서드는 `cursor=` 키워드를 받고, 모든 결과에는 `next_cursor`가 있습니다. `next_cursor`가 `None`이면 전부 받은 것입니다. -```python title="client.py" hl_lines="22-30" +```python title="client.py" hl_lines="7-15" --8<-- "docs_src/client/tutorial007.py" ``` -이 루프는 어떤 서버에 대해서도 올바릅니다. `MCPServer`는 모든 것을 한 페이지에 반환하므로 `next_cursor`는 `None`이고 루프는 한 번만 돌며, 그래서 대부분의 코드는 이 루프를 작성하지 않습니다. 실제로 페이지를 나누는 서버와 커서가 따르는 규칙은 **[페이지네이션](../advanced/pagination.md)**에서 다룹니다. +`list_all_tools`는 어떤 서버에 대해서도 올바르게 동작합니다. `MCPServer`는 모든 것을 한 페이지에 반환하므로 `next_cursor`는 `None`이고 루프는 한 번만 돌며, 그래서 대부분의 코드는 이 루프를 작성하지 않습니다. 실제로 페이지를 나누는 서버와 커서가 따르는 규칙은 **[페이지네이션](../advanced/pagination.md)**에서 다룹니다. ## 테스트에서 {#in-tests} -프로세스도 포트도 없는 `Client(mcp)`는 그 자체로 이미 서버의 테스트 하네스입니다. +이 페이지의 모든 `client.py`는 HTTP로 `server.py`에 연결했습니다. 테스트에서는 네트워크를 건너뛰고 `Client`에 서버 객체 자체를 넘깁니다. `from server import mcp`를 한 다음 `Client(mcp)`를 만들면 됩니다. 프로세스도 포트도 없으며, 위의 모든 메서드가 똑같이 동작합니다. -이를 위해 만들어진 생성자 플래그가 하나 있습니다. `Client(mcp, raise_exceptions=True)`입니다. 인메모리 연결에서만 효과가 있으며, 이를 설명하고 전체 패턴을 구축하는 페이지는 **[테스트](../get-started/testing.md)**입니다. +이를 위해 만들어진 생성자 플래그가 하나 있습니다. `Client(mcp, raise_exceptions=True)`입니다. 프로세스 내부 연결에서만 효과가 있으며, 이를 설명하고 전체 패턴을 구축하는 페이지는 **[테스트](../get-started/testing.md)**입니다. ## 요약 {#recap} -* `Client(x)`는 서버 객체에는 인메모리로, URL 문자열에는 Streamable HTTP로, 그 밖의 것에는 트랜스포트를 통해 연결합니다. +* `Client(x)`는 URL 문자열에는 Streamable HTTP로 연결하고, `StdioServerParameters`에는 서브프로세스를 실행하며, 트랜스포트에는 직접 들어가고, 테스트에서는 서버 객체 자체를 받습니다. * `async with`가 생명 주기의 전부입니다. 그 안에서는 `server_capabilities`와 `protocol_version`이 이미 채워져 있으며, 서버가 제공하는 경우 `server_info`와 `instructions`도 마찬가지입니다. * `list_tools()`는 각 도구의 `name`, `title`, `description`, `input_schema`를 제공합니다. * `call_tool()`은 모델을 위한 `content`, 코드를 위한 `structured_content`, 그리고 `is_error`를 반환합니다. 예외를 발생시키는 도구는 예외가 아니라 결과입니다. diff --git a/i18n/ko/pages/client/oauth-clients.md b/i18n/ko/pages/client/oauth-clients.md index 534311a9..91d99477 100644 --- a/i18n/ko/pages/client/oauth-clients.md +++ b/i18n/ko/pages/client/oauth-clients.md @@ -1,6 +1,6 @@ --- translation: - sections: [c6899d3892bd9fa0, 79372cff3cc48a88, 63878d29e87c3e73, 13175843d3588af4, e7e2b9fd516f77de, 758f06399b513c1f, a05d7278487d610b] + sections: [c6899d3892bd9fa0, 79372cff3cc48a88, c2dae1ebe2ebd543, 13175843d3588af4, df06056fb16b3846, 758f06399b513c1f, a05d7278487d610b] tool: 1 --- # OAuth 클라이언트 {#oauth-clients} @@ -81,18 +81,20 @@ translation: `Client`가 처음 요청을 보내면 서버는 `401`로 응답합니다. 그러면 프로바이더가 이어받습니다. -1. **디스커버리.** `WWW-Authenticate` 헤더를 읽고, `/.well-known/oauth-protected-resource`에서 서버의 Protected Resource Metadata를 가져오고, 어느 인가 서버가 이 리소스를 보호하는지 알아낸 뒤, **그** 서버의 메타데이터를 가져옵니다. +1. **디스커버리.** `WWW-Authenticate` 헤더를 읽고, `/.well-known/oauth-protected-resource`에서 서버의 Protected Resource Metadata를 가져오고, 어느 인가 서버가 이 리소스를 보호하는지 알아낸 뒤, **그** 서버의 메타데이터를 가져옵니다. (리소스 메타데이터를 게시하지 않는 오래된 서버에는 대신 그 서버 자신의 오리진에서 인가 서버 메타데이터를 요청합니다.) 어느 경우든 메타데이터의 `issuer` 값은 그 메타데이터를 가져온 대상 서버를 가리켜야 하며, 다른 값이면 거부합니다. 2. **등록.** 저장소에 아무것도 없으면 `OAuthClientMetadata`로 동적으로 등록하고 결과를 저장합니다. 3. **인가.** PKCE 쌍과 `state`를 생성하고, 인가 URL을 조립하고, `redirect_handler`를 await한 다음, 코드를 받기 위해 `callback_handler`를 await합니다. 4. **교환.** 코드를 `OAuthToken`으로 교환해 저장하고, 원래 요청에 `Authorization: Bearer ...`를 붙여 다시 보냅니다. 그다음부터는 조용합니다. 토큰은 저장소에서 꺼내 쓰고, 만료된 액세스 토큰은 리프레시 토큰으로 갱신하며, 그 어느 것도 통하지 않을 때에만 흐름을 다시 실행합니다. +이 모든 요청에 적용되는 트랜스포트 규칙이 하나 있습니다. 바깥의 MCP 요청과 마찬가지로, 같은 오리진에 머물면서 메서드를 유지하는 리다이렉트(이를테면 끝 슬래시를 붙이는 307/308)만 따르고, 그 밖의 리다이렉트는 해당 URL이 응답하지 않는 것으로 취급합니다. + 이 가운데 직접 작성한 코드는 하나도 없습니다. 키워드 인자가 두 개 더 남아 있는데(`client_metadata_url`과 `validate_resource_url`), 이 파일에는 둘 다 필요 없습니다. 알아 둘 만한 것은 `client_metadata_url`이며, 아래에 별도 섹션이 있습니다. ### 직접 해 보기 {#try-it} -이 문서의 예제 대부분은 인메모리 `Client(server)`로 확인할 수 있습니다. 이 예제는 아닙니다. 이 흐름의 핵심이 HTTP `401`인데, 인메모리 클라이언트와 서버 사이에는 HTTP가 없기 때문입니다. +테스트에서 쓰는 인메모리 `Client(server)`는 여기서는 도움이 되지 않습니다. 이 흐름의 핵심이 HTTP `401`인데, 인메모리 클라이언트와 서버 사이에는 HTTP가 없기 때문입니다. 리포지토리에는 실제로 동작하는 버전이 들어 있습니다. `examples/servers/simple-auth/`는 독립 실행형 인가 서버와 보호된 MCP 서버를 실행하고, `examples/clients/simple-auth-client/`는 이 페이지의 클라이언트를 작은 CLI로 키운 것입니다. 그 README에 두 명령이 있습니다. 서버를 시작하고, 그 서버를 대상으로 클라이언트를 실행하면 네 단계가 지나가는 모습을 볼 수 있습니다. @@ -110,13 +112,14 @@ URL은 루트가 아닌 경로를 가진 HTTPS여야 합니다. 그 외에는 `ClientCredentialsOAuthProvider`는 사람만 빠진 똑같은 `httpx2.Auth`입니다. -```python title="client.py" hl_lines="4 27-33" +```python title="client.py" hl_lines="4 27-34" --8<-- "docs_src/oauth_clients/tutorial002.py" ``` 달라진 점은 다음과 같습니다. * `OAuthClientMetadata`도 핸들러도 없습니다. `client_id`와 `client_secret`을 전달하면 프로바이더가 이를 감싸는 최소한의 `client_credentials` 등록을 만들고 동적 등록은 완전히 건너뜁니다. +* `issuer`는 그 자격 증명을 발급한 인가 서버를 지목합니다. 그 서버의 `/.well-known/oauth-authorization-server` 문서가 반환하는 `issuer` 값을 쓰세요. 디스커버리는 위와 똑같이 실행되지만, 토큰 요청은 오직 **그** 발급자의 메타데이터로만 조립됩니다. MCP 서버가 다른 곳을 가리키면 흐름은 대신 `OAuthFlowError`와 함께 멈춥니다. 이 값을 생략하는 것은 지원 중단 예정이며 3.0에서는 필수가 됩니다(**[지원 중단 예정 기능](../deprecated.md#deprecated-sdk-helpers)** 참고). 그때까지는 프로바이더가 경고를 내고 디스커버리가 찾아낸 인가 서버를 그대로 씁니다. * `scope`는 공백으로 구분한 문자열로, OAuth의 전송 형식입니다. * 그 아래는 모두 동일합니다. 같은 `TokenStorage`, 같은 `httpx2.AsyncClient(auth=...)`, 같은 `streamable_http_client`를 씁니다. @@ -129,8 +132,8 @@ URL은 루트가 아닌 경로를 가진 HTTPS여야 합니다. 그 외에는 `mcp.client.auth.extensions.client_credentials`에는 프로바이더가 하나 더 있습니다. 공유 시크릿 대신 JWT로 인증하는 클라이언트를 위한 **`PrivateKeyJWTOAuthProvider`**입니다 (`private_key_jwt`, 즉 키 쌍과 워크로드 아이덴티티 방식). 같은 패턴을 따릅니다. - 하나를 생성해 `auth=`에 넣으면 됩니다. 같은 모듈에는 그 어설션을 만드는 두 헬퍼인 - `SignedJWTParameters`와 `static_assertion_provider`도 들어 있습니다. + 하나를 생성해(선택 인자 `issuer`를 똑같이 받습니다) `auth=`에 넣으면 됩니다. 같은 모듈에는 + 그 어설션을 만드는 두 헬퍼인 `SignedJWTParameters`와 `static_assertion_provider`도 들어 있습니다. 사람이 없는 상황이 하나 더 있습니다. 클라이언트가 기업에 속해 있고, 어느 MCP 서버에 접근할 수 있는지를 사용자가 아니라 그 기업의 아이덴티티 공급자가 결정하는 경우입니다. 이는 고유한 신뢰 모델을 가진 다른 그랜트이며, 별도 페이지인 **[아이덴티티 어설션](identity-assertion.md)**에서 다룹니다. diff --git a/i18n/ko/pages/client/session-groups.md b/i18n/ko/pages/client/session-groups.md index be15ae20..fc78a899 100644 --- a/i18n/ko/pages/client/session-groups.md +++ b/i18n/ko/pages/client/session-groups.md @@ -1,6 +1,6 @@ --- translation: - sections: [09c857a25a9dc37a, 43bc6a76a243a50e, 0a716022a88768df, 4b7f78042bfcfff7, c112662e61b03315, 58974ba1f489a8b4, d18adbdbb835ea73] + sections: [09c857a25a9dc37a, 43bc6a76a243a50e, 0a716022a88768df, 4b7f78042bfcfff7, c112662e61b03315, 58974ba1f489a8b4, ed4d17e894864056] tool: 1 --- # 세션 그룹 {#session-groups} @@ -78,7 +78,7 @@ translation: ## 요약 {#recap} * `ClientSessionGroup`은 여러 서버 연결을 담고 도구, 리소스, 프롬프트를 각각 하나의 `dict`로 합칩니다. -* 서버마다 `connect_to_server(params)`를 호출합니다. `Client`가 받는 서버 객체나 URL이 아니라 트랜스포트 매개변수를 받습니다. +* 서버마다 `connect_to_server(params)`를 호출합니다. `Client`가 받는 URL이나 `Transport`가 아니라 트랜스포트 매개변수를 받습니다. * `group.call_tool(name, arguments)`는 소유한 서버로 알아서 라우팅합니다. * 이름은 그룹 전체에서 고유해야 합니다. `search` 도구를 가진 두 서버는 그대로는 공존할 수 없습니다. * `component_name_hook=`은 등록되는 모든 이름을 다시 씁니다. 딕셔너리 키는 바뀌지만 전송되는 이름은 바뀌지 않습니다. diff --git a/i18n/ko/pages/client/transports.md b/i18n/ko/pages/client/transports.md index 801f67be..19b50e2e 100644 --- a/i18n/ko/pages/client/transports.md +++ b/i18n/ko/pages/client/transports.md @@ -1,6 +1,6 @@ --- translation: - sections: [9cac816674181eb0, 0700f337babcd4dd, 2bde0dd58cdf00f5, 40b4916d82eaf1d4, 3d0832f39b0d7059, dfa4446556badef0, 5bd93be2ab2ecb9c] + sections: [9cac816674181eb0, 7c157764133fea1f, 40b4916d82eaf1d4, 10d151f2cc75317f, 3d0832f39b0d7059, 92742ba36533633d, 0aeca6145e7bd302] tool: 1 --- # 클라이언트 트랜스포트 {#client-transports} @@ -11,30 +11,15 @@ translation: 각 트랜스포트의 **서버** 쪽(`mcp.run()`이 하는 일과 배포 대상)은 **[서버 실행하기](../run/index.md)**에서 다룹니다. -## 인메모리 {#in-memory} - -서버 객체 자체를 전달하세요. - -```python title="client.py" hl_lines="14" ---8<-- "docs_src/client_transports/tutorial001.py" -``` - -서브프로세스도, 포트도, 네트워크를 오가는 바이트도 없습니다. 클라이언트와 서버는 같은 프로세스 안의 두 객체일 뿐이지만, 호출은 여전히 실제 프로토콜 계층을 거칩니다. `search_books`는 HTTP를 통할 때와 똑같이 나열되고, 검증되고, 호출됩니다. - -덕분에 이 방식은 동시에 두 가지 역할을 합니다. - -* **테스트 도구.** 이 문서의 모든 예제는 이 방식으로 실행되며, **[테스트](../get-started/testing.md)** 페이지는 전체 패턴을 이 방식 위에 구축합니다. -* **임베딩 API.** 서버를 직접 생성하는 애플리케이션은 도구를 호출하기 위해 네트워크를 거칠 필요가 없습니다. - ## Streamable HTTP {#streamable-http} -URL 문자열을 전달하면 **Streamable HTTP**를 얻습니다. 배포 시 사용하는 트랜스포트입니다. +URL 문자열을 전달하면 **Streamable HTTP**를 사용합니다. 배포할 때 그 뒤에 서버를 두는 트랜스포트이자, 가장 먼저 선택해야 할 트랜스포트입니다. ```python title="client.py" hl_lines="5" --8<-- "docs_src/client_transports/tutorial002.py" ``` -이것이 프로덕션 클라이언트의 전부입니다. `Client`가 URL을 `streamable_http_client(...)`로 감싸 주며, 그 아래에는 MCP에 맞게 설정된 `httpx2.AsyncClient`가 있습니다. `follow_redirects=True`, connect/write/pool에 30초 타임아웃, 그리고 서버가 응답 스트림을 열어 둘 수 있으므로 read에는 300초 타임아웃이 적용됩니다. +이것이 프로덕션 클라이언트의 전부입니다. `Client`가 URL을 `streamable_http_client(...)`로 감싸 주며, 그 아래에는 MCP에 맞게 설정된 `httpx2.AsyncClient`가 있습니다. connect/write/pool에는 30초 타임아웃, 그리고 서버가 응답 스트림을 열어 둘 수 있으므로 read에는 300초 타임아웃이 적용됩니다. !!! check 생성만 한 `Client`는 **연결되지 않은** 상태입니다. 생성은 트랜스포트를 고를 뿐이고, @@ -50,7 +35,7 @@ URL 문자열을 전달하면 **Streamable HTTP**를 얻습니다. 배포 시 `Authorization` 헤더, 쿠키, 프록시, mTLS, 다른 타임아웃이 필요해지는 순간, `httpx2.AsyncClient`를 직접 만들어 `streamable_http_client`에 넘기세요. -```python title="client.py" hl_lines="8-14" +```python title="client.py" hl_lines="8-13" --8<-- "docs_src/client_transports/tutorial003.py" ``` @@ -80,9 +65,30 @@ TLS 관련 참고 사항이 하나 있습니다. `httpx2`는 번들된 CA 목록 !!! info `httpx2`는 익숙한 `httpx` API를 그대로 유지하므로, `httpx`를 안다면 여기서 인증, 프록시, 이벤트 훅, 재시도, 연결 제한을 다루는 방법도 이미 아는 셈입니다. SDK는 그 위에 아무것도 - 더하지 않고 아무것도 빼지 않습니다. OAuth가 연결되는 지점도 여기입니다. + 더하지 않고 아무것도 빼지 않습니다. 단, [리디렉션 처리](#redirects)만은 예외입니다. OAuth가 연결되는 지점도 여기입니다. `httpx2.AsyncClient(auth=OAuthClientProvider(...))`. 전체 흐름은 **[OAuth 클라이언트](oauth-clients.md)**에서 다룹니다. +### 리디렉션 {#redirects} + +트랜스포트는 전달받은 URL에 연결하며, 그 오리진에만 연결합니다. + +* 같은 스킴, 호스트, 포트 안에 머무는 `307`/`308` 리디렉션은 따라가며, 같은 호스트에서 `http://` → `https://`로 가는 리디렉션도 따라갑니다. 흔한 `/mcp` → `/mcp/` 후행 슬래시 리디렉션이 여기에 포함됩니다. +* 그 밖의 곳으로 가는 리디렉션은 따라가지 **않습니다**. 호출은 다음 오류로 실패합니다. + + ```text + MCPError: Redirect to https://other.example.com/mcp not followed; use that URL as the endpoint if it is the intended server + ``` + + 그 URL이 의도한 서버라면 설정에 그 URL을 넣으세요. 아니라면 서버나 그 앞의 프록시 설정이 잘못된 것입니다. + +이 규칙은 전달하는 어떤 `httpx2.AsyncClient`에도 적용됩니다. MCP 요청에는 `follow_redirects` 설정이 어느 방향으로든 참조되지 않습니다. SDK의 OAuth 프로바이더도 자체 요청에 같은 규칙을 적용합니다. + +!!! tip + `Redirect to http://… not followed: it would downgrade this HTTPS endpoint to plain HTTP`는 + 서버가 자신이 모르는 TLS 종료 프록시 뒤에 있어 `http://` 리디렉션을 내보내고 있다는 뜻입니다. + 이는 서버 쪽에서 고치거나(**[배포와 확장](../run/deploy.md#behind-a-tls-terminating-proxy)**), + 메시지가 제안하는 정확한 `https://…/` URL을 사용해 해결합니다. + ## stdio {#stdio} **stdio** 서버는 서브프로세스입니다. 클라이언트가 이를 실행하고, stdin에 JSON-RPC를 쓰고, stdout에서 JSON-RPC를 읽습니다. 데스크톱 호스트가 사용자 컴퓨터에서 서버를 실행하는 방식이 바로 이것입니다. 호스트는 **곧** 이 코드에 UI를 더한 것이며, **[실제 호스트에 연결하기](../get-started/real-host.md)**는 같은 관계를 호스트 쪽에서 설정 파일로 바라본 것입니다. @@ -105,6 +111,18 @@ TLS 관련 참고 사항이 하나 있습니다. `httpx2`는 번들된 CA 목록 API 키가 필요한 서버는 거기서 키를 찾지 못합니다. `env=`로 명시적으로 전달하세요. 해당 변수는 허용 목록 위에 병합됩니다. 위 예제에서 `BOOKSHOP_API_KEY`가 하는 일이 바로 이것입니다. +## 인메모리 {#in-memory} + +테스트에서는 배포할 것도, 실행할 것도 없습니다. 서버 객체 자체를 전달하세요. + +```python hl_lines="14" +--8<-- "docs_src/client_transports/tutorial001.py" +``` + +서브프로세스도, 포트도, 네트워크를 오가는 바이트도 없습니다. 클라이언트와 서버는 같은 프로세스 안의 두 객체이며, 호출은 여전히 실제 프로토콜 계층을 거칩니다. `search_books`는 HTTP를 통할 때와 똑같이 나열되고, 검증되고, 호출됩니다. **[테스트](../get-started/testing.md)** 페이지는 전체 패턴을 이 방식 위에 구축합니다. + +같은 형태가 임베딩 API 역할도 겸합니다. 서버를 직접 생성하는 애플리케이션은 네트워크를 거치지 않고 서버의 도구를 호출할 수 있습니다. + ## SSE {#sse} `mcp.client.sse`의 `sse_client(url)`은 Streamable HTTP로 대체된 이전 HTTP 트랜스포트입니다. 아직 이 방식을 쓰는 서버와 통신하려면 `Client(sse_client("http://localhost:8000/sse"))`처럼 같은 방식으로 감싸서 사용하되, 새로운 것을 이 위에 만들지는 마세요. @@ -113,15 +131,16 @@ TLS 관련 참고 사항이 하나 있습니다. `httpx2`는 번들된 CA 목록 `Client`에게 위의 모든 것은 같은 것입니다. -**트랜스포트**란 `(read, write)` 메시지 스트림 쌍을 내어주는 비동기 컨텍스트 매니저라면 무엇이든 해당합니다. 정식으로는 `mcp.client`의 `Transport` 프로토콜입니다. `Client`는 인자를 타입으로 구분합니다. 서버 객체는 프로세스 내에서 연결하고, `str`은 `streamable_http_client(url)`이 되며, `StdioServerParameters`는 `stdio_client(params)`가 되고, 그 밖의 것은 트랜스포트로 직접 진입합니다. 마지막 규칙 덕분에 `stdio_client(...)`, `streamable_http_client(...)`, `sse_client(...)`가 모두 같은 자리에 들어가고, 직접 만든 트랜스포트도 쓸 수 있습니다. +**트랜스포트**란 `(read, write)` 메시지 스트림 쌍을 내어주는 비동기 컨텍스트 매니저라면 무엇이든 해당합니다. 정식으로는 `mcp.client`의 `Transport` 프로토콜입니다. `Client`는 인자를 타입으로 구분합니다. `str`은 `streamable_http_client(url)`이 되고, `StdioServerParameters`는 `stdio_client(params)`가 되며, 서버 객체는 프로세스 내에서 연결하고, 그 밖의 것은 트랜스포트로 직접 진입합니다. 마지막 규칙 덕분에 `stdio_client(...)`, `streamable_http_client(...)`, `sse_client(...)`가 모두 같은 자리에 들어가고, 직접 만든 트랜스포트도 쓸 수 있습니다. ## 요약 {#recap} -* `Client(mcp)`(서버 객체)는 인메모리로 연결합니다. 테스트와 임베딩에 사용하세요. * `Client("http://.../mcp")`(URL)는 프로덕션 트랜스포트인 Streamable HTTP로 연결합니다. * 헤더, 인증, 프록시, 타임아웃은 `streamable_http_client(url, http_client=...)`에 전달하는 `httpx2.AsyncClient`에 설정합니다. `headers=` 키워드는 없습니다. +* 리디렉션은 URL 자체의 오리진 안에서만(후행 슬래시 `307`/`308`) 따라가며, 같은 호스트의 `http`→`https`도 따라갑니다. 그 밖의 경우는 `Redirect to … not followed`로 실패하므로 최종 URL을 설정하세요. * stdio는 `Client(StdioServerParameters(...))`입니다. 자식 프로세스의 stderr를 다른 곳으로 돌릴 때만 직접 `stdio_client(...)`로 감싸세요. * 서브프로세스는 현재 환경이 아니라 허용 목록에 있는 환경 변수만 받습니다. `env=`로 여기에 추가합니다. +* `Client(mcp)`(서버 객체)는 인메모리로 연결합니다. 테스트에서 사용하거나, 서버를 만든 애플리케이션 안에 서버를 임베딩할 때 사용하세요. * 트랜스포트는 `async with x as (read, write)`로 쓸 수 있는 것이면 무엇이든 됩니다. `Client`는 서버 객체, URL, `StdioServerParameters`가 아닌 것은 모두 그 프로토콜에 그대로 넘깁니다. * `Client`를 생성하면 트랜스포트가 정해집니다. `async with`가 이를 엽니다. diff --git a/i18n/ko/pages/deprecated.md b/i18n/ko/pages/deprecated.md index 63d1d27a..cc1413a6 100644 --- a/i18n/ko/pages/deprecated.md +++ b/i18n/ko/pages/deprecated.md @@ -1,11 +1,11 @@ --- translation: - sections: [490237e61c3a7a44, 01262a123ad9501d, 429db5b574a2ac08, e2d0d273fbd2d74b, 64ab0331e868f3d4, 6c8878ce2d1f6d56, 4068f23e371bf0b3, eaef75b8725bc931] + sections: [4c1dea378b2b1bf7, 01262a123ad9501d, 429db5b574a2ac08, e2d0d273fbd2d74b, 64ab0331e868f3d4, 6c8878ce2d1f6d56, c3d1099701156881, e185a1b8e53669f6] tool: 1 --- # 지원 중단 예정 기능 {#deprecated-features} -2026-07-28 사양은 다섯 가지를 퇴역시킵니다. SDK는 여전히 이 다섯 가지를 모두 구현하며, 이제 모두에 **지원 중단 예정(deprecated) 경고**가 붙습니다. SDK 헬퍼 하나는 별도의 이유로 지원 중단 예정이며 [페이지 끝](#deprecated-sdk-helpers)에 정리되어 있습니다. +2026-07-28 사양은 다섯 가지를 퇴역시킵니다. SDK는 여전히 이 다섯 가지를 모두 구현하며, 이제 모두에 **지원 중단 예정(deprecated) 경고**가 붙습니다. 몇 가지 SDK 수준의 지원 중단 예정 항목은 사양과 별개의 이유로 지정된 것으로, [페이지 끝](#deprecated-sdk-helpers)에 정리되어 있습니다. 아래 표는 지원 중단 예정인 각 기능의 이름, 사라지는 이유, 그리고 대신 사용할 대체 수단을 정리한 것입니다. @@ -136,11 +136,13 @@ warnings.filterwarnings("ignore", category=MCPDeprecationWarning) ## 지원 중단 예정 SDK 헬퍼 {#deprecated-sdk-helpers} -이것은 사양 변경이 아니라 더 나은 대체 수단이 있는 SDK 내부 구현일 뿐입니다. 같은 `MCPDeprecationWarning`으로 경고하며 3.0에서 제거됩니다. +이것은 사양 변경이 아니라 더 나은 대체 수단이 있는 SDK 사용 방식일 뿐입니다. 같은 `MCPDeprecationWarning`으로 경고하며, 3.0에서 기존 형태가 제거됩니다. | 지원 중단 예정 | 대신 할 일 | |---|---| | `FuncMetadata.call_fn_with_arg_validation()` | `FuncMetadata.validate_arguments()`를 호출한 뒤 `FuncMetadata.call_fn()`을 호출하세요. `FuncMetadata`를 직접 다루는 코드(예를 들어 사용자 정의 `Tool` 하위 클래스)만 이 메서드를 호출했습니다. | +| `validate_token_resource=` 없이 쓰는 `AuthSettings(resource_server_url=...)` | 값을 지정하세요. `True`로 설정하면 검증기가 `resource_server_url`용으로 발급되었다고 보고하지 않는 베어러 토큰을 서버가 거부하고, `False`는 검증기가 토큰의 대상(audience)을 직접 확인한다는 뜻입니다(**[인가](run/authorization.md#a-token-verifier)** 참고). 지정하지 않으면 `False`처럼 동작하며, 3.0부터는 `resource_server_url`이 설정되어 있으면 `True`가 기본값이 됩니다. | +| `issuer=` 없이 쓰는 `ClientCredentialsOAuthProvider(...)` 또는 `PrivateKeyJWTOAuthProvider(...)` | 자격 증명을 발급한 인가 서버를 지정하는 `issuer=`를 전달하세요(**[OAuth 클라이언트 작성하기](client/oauth-clients.md#machine-to-machine)** 참고). 이 값이 없으면 어느 인가 서버가 자격 증명을 받을지를 MCP 서버가 결정하게 됩니다. 3.0부터는 이 키워드가 필수입니다. | ## 요약 {#recap} @@ -149,7 +151,7 @@ warnings.filterwarnings("ignore", category=MCPDeprecationWarning) * 지원 중단 예정은 권고 사항입니다. 와이어 변경은 없고, 2026년 이전 세션에서는 모든 것이 계속 동작하며, 눈에 띄는 `MCPDeprecationWarning`이 나옵니다(`UserWarning`이므로 기본적으로 켜져 있습니다). * 샘플링과 루트는 추가로 2026-07-28 세션에는 없는 백채널이 필요합니다. 최신 연결에서는 경고를 낸 뒤 예외를 발생시킵니다. * `warnings.filterwarnings("ignore", category=MCPDeprecationWarning)`은 카테고리 전체를 끄고, pytest의 `"error::mcp.MCPDeprecationWarning"`은 이를 테스트 실패로 바꿉니다. -* SDK 헬퍼 하나인 `FuncMetadata.call_fn_with_arg_validation()`은 별도로 지원 중단 예정이며 3.0에서 제거됩니다. +* [SDK 수준의 지원 중단 예정 항목](#deprecated-sdk-helpers)도 같은 규칙을 따릅니다. 지금은 경고를 내고, 3.0에서 기존 형태가 제거됩니다. * 새 코드는 이 기능 중 어느 것에도 기반해서는 안 됩니다. 이 문서의 다른 모든 페이지는 현재 API를 설명합니다. diff --git a/i18n/ko/pages/get-started/first-steps.md b/i18n/ko/pages/get-started/first-steps.md index dab57cc9..19e9791b 100644 --- a/i18n/ko/pages/get-started/first-steps.md +++ b/i18n/ko/pages/get-started/first-steps.md @@ -1,6 +1,6 @@ --- translation: - sections: [0d6c05bcbf836bf3, 59a7b14eeefc68c1, 7114d8d6daba203f, e8bbb56a98ba7bc9, 5138010f6159901c, f78da7c7c363d4c6, 220a939cab348686] + sections: [0d6c05bcbf836bf3, 9a78b5f6b44b18ab, 7114d8d6daba203f, e8bbb56a98ba7bc9, bfd2fd1153e71dac, 1615a994ef071fdd, 65c599fae991f245] tool: 1 --- # 첫걸음 {#first-steps} @@ -17,7 +17,7 @@ translation: * **클라이언트**는 호스트 안에 있으며 MCP로 통신합니다. 호스트는 연결된 서버마다 클라이언트를 하나씩 실행합니다. * **서버**는 이 SDK로 만드는 것입니다. 서버는 클라이언트에 여러 가지를 노출하며, 모델과 직접 대화하는 일은 없습니다. -직접 작성하는 것은 서버입니다. 호스트는 다른 누군가가 만든 제품입니다. SDK는 `Client`도 제공합니다. 서버를 테스트할 때 쓰게 되며, 이 페이지 뒷부분에서 다시 등장합니다. +직접 작성하는 것은 서버입니다. 호스트는 다른 누군가가 만든 제품입니다. SDK는 `Client`도 제공합니다. 호스트가 URL로 서버에 접속하거나 서버를 서브프로세스로 실행할 때 쓰는 바로 그 클래스입니다. 이 페이지 뒷부분에서 다시 등장하며, 작성한 서버를 테스트하는 수단이기도 합니다. ## 세 가지 프리미티브 {#the-three-primitives} @@ -82,22 +82,20 @@ Inspector에서 탭 세 개를 보았습니다. Inspector가 세 개라는 것 클라이언트가 연결하면 서버는 **기능**, 즉 어떤 부류의 요청에 응답할지를 선언합니다. 클라이언트는 이 선언을 보고 애초에 무엇을 요청할지 결정합니다. 이 선언을 작성한 적은 없습니다. `MCPServer`가 대신 선언합니다. -직접 확인해 보세요. SDK의 `Client`는 서버 객체를 그대로 받아 **인메모리**로 연결합니다(서브프로세스도, 포트도 없습니다). +직접 확인해 보세요. 한쪽 터미널에서 `server.py`를 HTTP로 실행해 두세요. -```python -import asyncio +```console +uv run mcp run server.py --transport streamable-http +``` -from mcp import Client +그리고 다른 터미널에서 클라이언트를 이 서버에 연결하세요. -from server import mcp +```python title="client.py" hl_lines="7-8" +--8<-- "docs_src/first_steps/tutorial001_client.py" +``` - -async def main() -> None: - async with Client(mcp) as client: - print(client.server_capabilities.model_dump(exclude_none=True)) - - -asyncio.run(main()) +```console +python client.py ``` ```text @@ -117,8 +115,9 @@ asyncio.run(main()) 없는 것에도 주목하세요. `completions`(리소스 템플릿과 프롬프트의 인자 자동 완성)에는 직접 작성하는 핸들러가 필요한데, 이 서버에는 핸들러가 없으므로 해당 기능이 빠져 있고, 올바르게 동작하는 클라이언트라면 요청하지 않습니다. 선택 사항은 모두 이 규칙을 따릅니다. 등록하면 기능이 나타납니다. **[자동 완성](../servers/completions.md)** 페이지가 이를 보여 줍니다. !!! info - `Client(mcp)`는 이 문서의 모든 예제를 테스트하는 데 쓰이는 바로 그 인메모리 클라이언트이며, - 작성한 서버도 같은 방식으로 테스트하게 됩니다. 이를 다루는 페이지가 따로 있습니다. **[테스트](testing.md)**입니다. + 이 `client.py`는 완전한 MCP 클라이언트이며, 이를 다루는 페이지는 **[클라이언트](../client/index.md)**입니다. + 테스트에서는 터미널도 포트도 건너뛰고 `Client(mcp)`처럼 `Client`에 서버 객체 자체를 넘깁니다. + 이 역시 전용 페이지가 있습니다. **[테스트](testing.md)**입니다. ## 작성하지 않은 것 {#what-you-did-not-write} @@ -127,7 +126,7 @@ asyncio.run(main()) * JSON Schema. `a: int, b: int`가 **곧** `add`의 스키마입니다. * 요청 핸들러. `tools/list`, `resources/read`, `prompts/get`은 모두 대신 처리됩니다. * 기능 선언. `MCPServer`가 대신 만들었습니다. -* 프로토콜 코드 단 한 줄. 버전 협상, JSON-RPC 프레이밍, 기능 교환은 모두 `mcp dev`와 `Client(mcp)` 안에서 일어났고, 눈에 보이지도 않았습니다. +* 프로토콜 코드 단 한 줄. 버전 협상, JSON-RPC 프레이밍, 기능 교환은 모두 `mcp dev`와 `client.py` 안에서 일어났고, 눈에 보이지도 않았습니다. 이 비율이야말로 SDK가 존재하는 이유입니다. @@ -138,6 +137,6 @@ asyncio.run(main()) * 프리미티브마다 데코레이터 하나면 됩니다. `@mcp.tool()`, `@mcp.resource(uri)`, `@mcp.prompt()`입니다. 이름, 설명, 스키마는 함수에서 가져옵니다. * `{param}`이 들어간 URI는 리소스 **템플릿**을 만들며, 구체적인 리소스와는 따로 나열됩니다. * 서버의 **기능**은 자동으로 선언되며, 클라이언트는 서버가 선언한 것만 요청합니다. -* `Client(mcp)`는 서버 객체에 인메모리로 연결합니다. 첫날부터 갖추는 테스트 하네스입니다. +* `Client("http://localhost:8000/mcp")`는 실행 중인 서버와 통신합니다. 대신 서버 객체를 넘겨 `Client(mcp)`로 만들면 첫날부터 쓰는 테스트 하네스가 됩니다. 다음은 **[실제 호스트에 연결하기](real-host.md)**입니다. 이 서버를 Claude Desktop이나 IDE 안에서 실제로 돌려 봅니다. 그다음은 **[테스트](testing.md)**입니다. 페이지 하나, 인메모리 클라이언트 하나면 동작하는지 추측할 일이 없어집니다. 그 뒤로는 프리미티브마다 전용 페이지가 이어지며, 모델이 주도하는 프리미티브인 **[도구](../servers/tools.md)**부터 시작합니다. diff --git a/i18n/ko/pages/get-started/testing.md b/i18n/ko/pages/get-started/testing.md index 2537f1b0..db1a83dc 100644 --- a/i18n/ko/pages/get-started/testing.md +++ b/i18n/ko/pages/get-started/testing.md @@ -1,13 +1,13 @@ --- translation: - sections: ['4926721070127497', c52a1de2b6b32f40, 8e792bf8c7489ec6, 627195f7159e24ef] + sections: [5d13c2f0ba42c0d2, c52a1de2b6b32f40, 8e792bf8c7489ec6, 38552ea228b0a04f] tool: 1 --- # 테스트 {#testing} -Python SDK는 **인메모리 트랜스포트**를 갖춘 `Client` 클래스를 제공합니다. 서버 객체를 넘기면 그 서버에 직접 연결합니다. +SDK의 `Client` 클래스는 URL에 연결하거나 서브프로세스를 띄울 때 쓰는 바로 그 클래스인데, **인메모리**로도 연결합니다. 서버 객체를 넘기면 그 서버와 직접 통신합니다. -서브프로세스도 없습니다. 포트도 없습니다. 트랜스포트도 전혀 없습니다. FastAPI의 `TestClient`와 같은 발상입니다. +서브프로세스도 없습니다. 포트도 없습니다. 네트워크를 오가는 것도 전혀 없습니다. FastAPI의 `TestClient`와 같은 발상입니다. ## 기본 사용법 {#basic-usage} @@ -96,14 +96,14 @@ async def test_call_add_tool(client: Client): 테스트에서는 켜 두세요. 프로덕션 코드에서는 아무 의미가 없습니다. -## 기본값은 인프로세스 연결 {#in-process-by-default} +## 기본값은 세대 중립 {#era-neutral-by-default} !!! note `Client(mcp)`는 인프로세스로 연결하며 기본적으로 **세대 중립적**(era-neutral)입니다. 서버를 - 조사해 알맞은 프로토콜 경로를 고릅니다. 테스트가 레거시 전용 동작, 즉 샘플링이나 - 엘리시테이션(elicitation) 푸시, `message_handler` 같은 것을 검증한다면 `mode="legacy"`로 - 고정하고, 그 경우에는 `raise_exceptions=True`를 빼세요. 레거시 연결은 애초에 정제를 하지 - 않으며, 이 플래그는 실패를 테스트가 아니라 서버 태스크 안에서 다시 발생시키기 때문입니다. + 조사해 알맞은 프로토콜 경로를 고릅니다. 테스트가 레거시 전용 동작(샘플링이나 + 엘리시테이션(elicitation) 푸시, `message_handler`)을 검증한다면 `mode="legacy"`로 고정하고, + 그 경우에는 `raise_exceptions=True`를 빼세요. 레거시 연결은 애초에 정제를 하지 않으며, 이 + 플래그는 실패를 테스트가 아니라 서버 태스크 안에서 다시 발생시키기 때문입니다. 이 문서의 예제가 실제로 동작한다고 약속할 수 있는 것도 바로 그 한 줄 덕분입니다. 모든 예제 파일은 SDK 자체의 테스트 스위트에서 실행되며, 거의 전부가 정확히 이 클라이언트를 거칩니다. diff --git a/i18n/ko/pages/handlers/multi-round-trip.md b/i18n/ko/pages/handlers/multi-round-trip.md index 3955da96..f2c57798 100644 --- a/i18n/ko/pages/handlers/multi-round-trip.md +++ b/i18n/ko/pages/handlers/multi-round-trip.md @@ -1,6 +1,6 @@ --- translation: - sections: [74011e683045eea9, 9b64cc175c18b6a9, 4b41be4824030397, e3b1502da786ec33, 71e41161f143c6a9, 9ec2c1eeb8c36378, 8dd027377d46448b, f81491125dcbfe8b] + sections: [74011e683045eea9, 9b64cc175c18b6a9, 4b41be4824030397, e3b1502da786ec33, 71e41161f143c6a9, 9ec2c1eeb8c36378, b47667184ca5b516, f81491125dcbfe8b] tool: 1 --- # 다중 왕복 요청 {#multi-round-trip-requests} @@ -164,7 +164,7 @@ TTL, 주체 바인딩, 요청 바인딩은 코덱의 일이 **아닙니다**. SD ## 2026-07-28 전용 결과 {#a-2026-07-28-result} -`InputRequiredResult`는 프로토콜 버전 **2026-07-28**에만 존재합니다. 인메모리 `Client(server)`는 이를 대신 협상하고, 네트워크를 통할 때는 `mode="auto"`가 이를 발견합니다. 연결한 뒤에는 `client.protocol_version`이 무엇을 얻었는지 알려 줍니다. +`InputRequiredResult`는 프로토콜 버전 **2026-07-28**에만 존재합니다. `Client`의 기본값인 `mode="auto"`는 어떤 연결에서든 이를 발견합니다. 연결한 뒤에는 `client.protocol_version`이 무엇을 얻었는지 알려 줍니다. !!! warning 2026 이전 세션에는 `InputRequiredResult`를 넣을 곳이 없습니다. `mode="legacy"` 연결에서 diff --git a/i18n/ko/pages/handlers/progress.md b/i18n/ko/pages/handlers/progress.md index 0b0bbd98..d576a917 100644 --- a/i18n/ko/pages/handlers/progress.md +++ b/i18n/ko/pages/handlers/progress.md @@ -1,6 +1,6 @@ --- translation: - sections: [5315262fe26b33e1, 9d8e98840f1b78f0, 0284b215e85366c4, 8534d8dbb4053a70, 2966fac6fe697007] + sections: [5315262fe26b33e1, 9d8e98840f1b78f0, 52d6009a07e770ea, 8534d8dbb4053a70, 2966fac6fe697007] tool: 1 --- # 진행 상황 {#progress} @@ -29,19 +29,17 @@ translation: 클라이언트는 **호출 단위로** 수신을 선택합니다. `call_tool`에 `progress_callback=` 인자를 전달하면 됩니다. -```python title="client.py" hl_lines="7 16" +```python title="client.py" hl_lines="5 14" import anyio from mcp import Client -from server import mcp - async def show(progress: float, total: float | None, message: str | None) -> None: print(f"{message} ({progress}/{total})") async def main() -> None: - async with Client(mcp) as client: + async with Client("http://localhost:8000/mcp") as client: result = await client.call_tool( "import_catalog", {"urls": ["https://example.com/a.json", "https://example.com/b.json"]}, @@ -56,27 +54,31 @@ anyio.run(main) 콜백은 서버가 보고한 값 그대로, 즉 `progress`, `total`, `message`를 받는 `async` 함수입니다. !!! info - `Client(mcp)`는 서버 객체에 메모리 안에서 직접 연결하며, **[테스트](../get-started/testing.md)** - 페이지의 기반이 되는 것과 같은 클라이언트입니다. `progress_callback`은 `Client`가 어떤 트랜스포트를 - 쓰든 같은 매개변수입니다. 다만 곧 보게 될 **타이밍**은 인메모리 연결의 타이밍입니다. 인메모리 연결은 - 콜백을 인라인으로 실행하므로 모든 보고가 `call_tool`이 반환되기 전에 도착합니다. 실제 트랜스포트에서는 - 알림과 결과가 경쟁하므로, 느린 콜백은 `call_tool`이 반환된 뒤에도 여전히 실행 중일 수 있습니다. + `progress_callback`은 `Client`에 무엇을 넘겼든 같은 매개변수입니다. 여기서처럼 URL이든, + `StdioServerParameters`든, 테스트에서 쓰는 서버 객체든 마찬가지입니다. 다만 실제 트랜스포트에서는 + 타이밍에 주의하세요. 각 알림은 응답과 별도로 따로 전달되므로, 느린 콜백은 `call_tool`이 반환된 + 뒤에도 여전히 실행 중일 수 있습니다. 프로세스 내 테스트 연결만이 콜백을 인라인으로 실행하여 모든 + 보고가 먼저 도착하도록 보장합니다. ### 직접 해 보기 {#try-it} -`client.py`를 `server.py` 옆에 두고 실행하세요. +`server.py`를 HTTP로 서비스한 다음, 두 번째 터미널에서 클라이언트를 실행하세요. + +```console +uv run mcp run server.py --transport streamable-http +``` ```console python client.py ``` ```text -Imported https://example.com/a.json (1/2) -Imported https://example.com/b.json (2/2) +Imported https://example.com/a.json (1.0/2.0) +Imported https://example.com/b.json (2.0/2.0) {'result': 'Imported 2 records.'} ``` -서버의 `await ctx.report_progress(...)` 하나하나가 클라이언트에서 `show` 호출 하나가 되었고, 순서도 그대로이며, 두 줄 모두 `call_tool`이 반환되기 **전에** 출력되었습니다. 진행 상황은 결과에 묶여 오지 않고, 도구가 아직 작업하는 동안 스트리밍됩니다. +서버의 `await ctx.report_progress(...)` 하나하나가 클라이언트에서 `show` 호출 하나가 되었고, 순서도 그대로입니다. 진행 상황은 결과에 묶여 오지 않습니다. 도구가 아직 작업하는 동안 스트리밍됩니다. !!! warning `progress_callback`은 `Client`가 아니라 **호출**에 속합니다. 이를 위한 생성자 인자는 없습니다. diff --git a/i18n/ko/pages/protocol-versions.md b/i18n/ko/pages/protocol-versions.md index 033e968c..be44b46c 100644 --- a/i18n/ko/pages/protocol-versions.md +++ b/i18n/ko/pages/protocol-versions.md @@ -1,6 +1,6 @@ --- translation: - sections: [478fd619e5f90ef8, aef094a00e44e248, bab8cbf3449fa7e9, df1809b15a58335b, 5f9d8c2336ed0239, f54974398e43ddef, b24443dd78584870] + sections: [d98e337bf28845e0, dd642acbba36f615, f547b3e76da19c94, 149513378588da5c, 4e149ed992046709, f54974398e43ddef, b24443dd78584870] tool: 1 --- # 프로토콜 버전 {#protocol-versions} @@ -11,9 +11,17 @@ MCP에는 두 시대가 있습니다. `Client`가 대신 협상하므로 신경 쓸 일은 거의 없습니다. 이 페이지는 이를 제어하는 단 하나의 생성자 인자인 `mode=`와 이 값을 바꾸게 되는 세 가지 경우를 다룹니다. +이 페이지의 모든 코드 조각은 **[클라이언트](client/index.md)**에 나온 Bookshop `server.py`와 통신하는 `client.py`입니다. 한 터미널에서 그 서버를 시작하세요. + +```console +uv run mcp run server.py --transport streamable-http +``` + +그런 다음 두 번째 터미널에서 `python client.py`로 각 코드 조각을 실행하세요. + ## `mode="auto"` {#modeauto} -```python title="client.py" hl_lines="14-15" +```python title="client.py" hl_lines="7-8" --8<-- "docs_src/protocol_versions/tutorial001.py" ``` @@ -31,13 +39,14 @@ MCP에는 두 시대가 있습니다. 이것이 기능의 전부입니다. `Client` 하나로 어느 시대의 서버든 상대하며, 코드에 분기가 필요 없습니다. !!! info - `MCPServer`는 인메모리, stdio, Streamable HTTP 등 모든 트랜스포트에서 `server/discover`에 - 응답하므로, 직접 작성한 서버를 상대로는 `auto`가 항상 `2026-07-28`에 도달합니다. 폴백은 - 실제 2026년 이전 서버를 상대할 때만 발동하며, 바로 그때가 폴백이 필요한 순간입니다. + `MCPServer`는 Streamable HTTP, stdio, 그리고 테스트에서 쓰는 프로세스 내 연결까지 모든 + 트랜스포트에서 `server/discover`에 응답하므로, 직접 작성한 서버를 상대로는 `auto`가 항상 + `2026-07-28`에 도달합니다. 폴백은 실제 2026년 이전 서버를 상대할 때만 발동하며, 바로 그때가 + 폴백이 필요한 순간입니다. ## `mode="legacy"` {#modelegacy} -```python title="client.py" hl_lines="14" +```python title="client.py" hl_lines="7" --8<-- "docs_src/protocol_versions/tutorial002.py" ``` @@ -61,7 +70,7 @@ MCP에는 두 시대가 있습니다. `mode`에는 최신 프로토콜 버전 문자열도 넣을 수 있습니다. 현재 그 집합은 정확히 `["2026-07-28"]`입니다. -```python title="client.py" hl_lines="14" +```python title="client.py" hl_lines="7" --8<-- "docs_src/protocol_versions/tutorial003.py" ``` @@ -94,7 +103,7 @@ ValueError: mode must be 'legacy', 'auto', or one of ['2026-07-28']; got '2025-0 그러니 보관해 두세요. `auto` 연결 후 `client.session.discover_result`에는 서버가 보낸 `DiscoverResult`가 그대로 담겨 있습니다. `supported_versions`, `capabilities`, `instructions`, 그리고 서버가 결과의 `_meta`에 새겨 넣은 신원 정보까지 포함됩니다. 다음번에는 이를 `prior_discover=`로 다시 넘기세요. -```python title="client.py" hl_lines="15 17" +```python title="client.py" hl_lines="8 10" --8<-- "docs_src/protocol_versions/tutorial004.py" ``` diff --git a/i18n/ko/pages/run/asgi.md b/i18n/ko/pages/run/asgi.md index 1c1795f0..46fe8f24 100644 --- a/i18n/ko/pages/run/asgi.md +++ b/i18n/ko/pages/run/asgi.md @@ -1,6 +1,6 @@ --- translation: - sections: [1062ef792791488a, 4be2b831547184a9, 374b049e770385f2, b72f6947089e6de0, b172c9db7831bb31, 70b9ece244ca1b0c, cba78e052898c3f6, f06bdb541cb0b469, fb82d526320b7cc3] + sections: [1062ef792791488a, 4be2b831547184a9, 374b049e770385f2, b72f6947089e6de0, b172c9db7831bb31, 10394c6f16601638, cba78e052898c3f6, f06bdb541cb0b469, 6dc898ccc5a903f9] tool: 1 --- # 기존 앱에 추가하기 {#add-to-an-existing-app} @@ -98,7 +98,7 @@ Starlette의 `Host` 라우트도 같은 방식으로 동작합니다. 경로 대 --8<-- "docs_src/asgi/tutorial004.py" ``` -이제 클라이언트는 `/notes/mcp`가 아니라 `/notes`에 연결합니다. +이제 클라이언트는 `/notes/mcp`가 아니라 `/notes/`에 연결합니다. ## 브라우저 클라이언트를 위한 CORS {#cors-for-browser-clients} @@ -141,4 +141,4 @@ Starlette의 `Host` 라우트도 같은 방식으로 동작합니다. 경로 대 * 브라우저 클라이언트에는 CORS가 필요합니다. `Mcp-*` 요청 헤더를 위한 `allow_headers`, 응답을 위한 `expose_headers=["Mcp-Session-Id"]`입니다. * `@mcp.custom_route()`는 `/mcp` 옆에 인증되지 않는 평범한 HTTP 엔드포인트를 추가합니다. -서버가 실제 URL로 도달 가능해지면, **[클라이언트](../client/index.md)**는 서버 객체 대신 그 URL로 연결합니다. +서버가 실제 URL로 도달 가능해지면, **[클라이언트](../client/index.md)**는 그 URL로 서버에 연결합니다. diff --git a/i18n/ko/pages/run/authorization.md b/i18n/ko/pages/run/authorization.md index 3e2e68a8..e26d3a9f 100644 --- a/i18n/ko/pages/run/authorization.md +++ b/i18n/ko/pages/run/authorization.md @@ -1,6 +1,6 @@ --- translation: - sections: [d62c13457fc4a534, 80e73abaca6e0652, d1dc4c54cd00ec9c, 14ad3bc7904036bb, 5225f127bc1b9c77, fe1626fdd5aad1da, 4556cb7ea1a04a31] + sections: [d62c13457fc4a534, 80e73abaca6e0652, 128a492d18295f64, 14ad3bc7904036bb, 54a6697833fcc1d9, fe1626fdd5aad1da, 811d083c1da8bcf6] tool: 1 --- # 인가 {#authorization} @@ -23,12 +23,12 @@ OAuth 용어로 말하면 서버는 **리소스 서버**입니다. 누구도 로 SDK는 유효한 토큰이 어떤 모습인지에 대해 아무런 의견이 없습니다. **`TokenVerifier`**를 구현해서 알려 주면 됩니다. -```python title="server.py" hl_lines="12-14 19-24" +```python title="server.py" hl_lines="14-16 21-27" --8<-- "docs_src/authorization/tutorial001.py" ``` * `TokenVerifier`는 async 메서드 하나를 가진 프로토콜입니다. `verify_token`은 `Authorization` 헤더에서 꺼낸 원시 토큰을 받아, 유효하면 **`AccessToken`**을, 유효하지 않으면 `None`을 반환합니다. 그 외에 구현할 것은 없습니다. -* 이 예제는 테이블에서 토큰을 조회합니다. 실제 구현은 JWT 서명을 검증하거나 인가 서버의 토큰 인트로스펙션 엔드포인트를 호출합니다. 그 코드는 직접 작성하는 것이고, SDK는 호출만 합니다. +* 이 예제는 테이블에서 토큰을 조회하며, 각 항목에는 그 토큰이 발급된 대상 리소스가 기록되어 있습니다. 실제 구현은 JWT 서명을 검증하거나 인가 서버의 토큰 인트로스펙션 엔드포인트를 호출하고, 토큰이 누구를 위해 발급되었는지(토큰의 `aud`)를 `AccessToken.resource`에 담아 알려 줍니다. 그 코드는 직접 작성하는 것이고, SDK는 호출만 합니다. * `token_verifier=`와 `auth=`는 항상 함께 다닙니다. 한쪽만 전달하면 `MCPServer(...)`가 요청을 하나도 처리하기 전에 `ValueError`를 발생시킵니다. `AuthSettings`는 리소스 서버의 공개 정보입니다. @@ -36,6 +36,10 @@ SDK는 유효한 토큰이 어떤 모습인지에 대해 아무런 의견이 없 * `issuer_url`: 토큰을 발급하는 인가 서버입니다. * `resource_server_url`: 이 MCP 엔드포인트의 공개 URL입니다. 토큰이 **어떤** 리소스를 위한 것인지 지칭하며, 디스커버리 문서가 위치하는 곳이기도 합니다. * `required_scopes`: 모든 토큰이 이 스코프를 전부 가지고 있어야 합니다. +* `validate_token_resource`: `AccessToken.resource`가 `resource_server_url`이 아닌 토큰은 모두 거부합니다. `resource_server_url`은 설정하고 이 값을 설정하지 않은 채로 두면 경고(`MCPDeprecationWarning`)가 발생하며 `False`로 동작합니다. 3.0에서는 리소스 서버의 기본값이 `True`가 됩니다. + * 인가 서버가 클라이언트가 요청한 `resource`에 토큰을 묶어 발급한다면 이 옵션을 켜세요. MCP 클라이언트는 이 값을 항상 보냅니다. `resource_server_url`은 클라이언트가 실제로 연결하는 URL과 정확히 같게 유지하세요. + * 인가 서버가 자체 audience 식별자(Auth0 API 식별자, Entra 애플리케이션 ID)를 사용한다면 이 옵션을 끄고, 대신 검증기에서 `aud`를 확인해 이 서버를 위한 토큰이 아니면 `None`을 반환하세요. + * `aud` 값이 리스트라면 `resource_server_url`과 같은 항목을 `resource`에 넣으세요. !!! tip SDK 저장소의 `examples/servers/simple-auth/`에는 실제 인가 서버의 @@ -91,7 +95,7 @@ SDK는 유효한 토큰이 어떤 모습인지에 대해 아무런 의견이 없 어떤 핸들러 안에서든 **`get_access_token()`**은 현재 요청에 대해 검증기가 반환한 `AccessToken`입니다. -```python title="server.py" hl_lines="4 32-35" +```python title="server.py" hl_lines="4 35-38" --8<-- "docs_src/authorization/tutorial002.py" ``` @@ -125,6 +129,6 @@ SDK는 리소스 서버 쪽 절반을 제공합니다. 검증하고, 알리고, * `token_verifier=`와 `auth=AuthSettings(issuer_url=..., resource_server_url=..., required_scopes=[...])`는 항상 함께 다닙니다. * SDK는 `/.well-known/oauth-protected-resource/...`에 [RFC 9728](https://datatracker.ietf.org/doc/html/rfc9728) Protected Resource Metadata를 게시하고, 인증되지 않은 요청에는 이 문서를 가리키는 `WWW-Authenticate` 헤더가 담긴 401로 응답합니다. 디스커버리는 이것이 전부입니다. * 어떤 핸들러에서든 `get_access_token()`이 곧 호출자입니다. -* 인가는 HTTP의 관심사입니다. `stdio`와 인메모리 클라이언트에서는 인가가 전혀 보이지 않습니다. +* 인가는 HTTP의 관심사입니다. `stdio`와 인메모리 테스트 클라이언트에서는 인가가 전혀 보이지 않습니다. 클라이언트 쪽 절반(인가 서버를 찾아내고 토큰을 대신 가져오는 일)은 **[OAuth 클라이언트](../client/oauth-clients.md)**에서 확인하세요. 그리고 사용자에게 신원을 묻는 대신 신원을 **어설션**하는 클라이언트는 **[ID 어설션](../client/identity-assertion.md)**에서 확인하세요. diff --git a/i18n/ko/pages/run/deploy.md b/i18n/ko/pages/run/deploy.md index 8c3384ba..c994ca1f 100644 --- a/i18n/ko/pages/run/deploy.md +++ b/i18n/ko/pages/run/deploy.md @@ -1,6 +1,6 @@ --- translation: - sections: [28221886b198784f, f88ea1f1614f3a1d, ce926d686730b6d0, 3be24f8ad8bb5ab9, 3fad24032b2224ff, f25a7f860e579ecb, e758745df6fb7b0a] + sections: [28221886b198784f, f88ea1f1614f3a1d, 2e76f5cb9df15042, ce926d686730b6d0, 3be24f8ad8bb5ab9, 3fad24032b2224ff, f25a7f860e579ecb, 697b01d95080880d] tool: 1 --- # 배포와 확장 {#deploy-scale} @@ -46,6 +46,22 @@ translation: 경고 한 줄로만 나타납니다. 새로 배포한 서버가 모든 연결을 거부한다면, 달리 밝혀지기 전까지는 Host 허용 목록 문제입니다. **[문제 해결](../troubleshooting.md)**도 여기서 시작합니다. +## TLS 종료 프록시 뒤에서 {#behind-a-tls-terminating-proxy} + +TLS가 프록시(인그레스, 로드 밸런서, Caddy, nginx)에서 끝나고 그 뒤에서 uvicorn이 평문 HTTP를 서비스한다면, uvicorn이 프록시의 `X-Forwarded-*` 헤더를 신뢰하도록 설정하세요. + +```console +uvicorn server:app --proxy-headers --forwarded-allow-ips='' +``` + +이 설정이 없으면 앱은 자신이 `http://`로 서비스되고 있다고 믿고, 앱이 내보내는 모든 리디렉션(흔한 것은 `/mcp` → `/mcp/`)이 `http://…`를 가리킵니다. Python 클라이언트는 HTTPS 엔드포인트에서 평문 HTTP로 따라가기를 거부하며 그 이유를 알려 줍니다. + +```text +MCPError: Redirect to http://mcp.example.com/mcp/ not followed: it would downgrade this HTTPS endpoint to plain HTTP. +``` + +클라이언트 쪽 임시방편은 서버가 서비스하는 정확한 URL(`https://mcp.example.com/mcp/`, 슬래시 포함)을 설정해 리디렉션이 일어나지 않게 하는 것입니다. 제대로 된 해결책은 위의 플래그입니다. `FORWARDED_ALLOW_IPS`는 같은 설정의 환경 변수 이름이며, `*` 값은 모든 홉을 신뢰하므로 프록시 말고는 아무것도 uvicorn에 닿을 수 없을 때만 맞는 설정입니다. + ## 워커, 그리고 스티키가 필요한 쪽 {#workers-and-who-has-to-be-sticky} 호스트명이 응답하기 시작했다면 그 뒤에 워커를 둘 이상 두세요. 이를 위한 SDK 설정은 없습니다. Starlette 앱은 다른 ASGI 앱과 똑같이, 포크할 줄 아는 도구에 객체를 넘겨서 확장합니다. @@ -170,6 +186,7 @@ python -c "import secrets; print(secrets.token_hex(32))" ## 요약 {#recap} * 기본적으로 이 앱은 localhost로 오는 요청만 받습니다. `transport_security=TransportSecuritySettings(allowed_hosts=[...], allowed_origins=[...])`가 서비스 개시의 관문입니다. 이를 전달하기 전까지 실제 호스트명 뒤의 모든 요청은 `421`이 되며, 그 이유는 서버 로그에만 남습니다. +* TLS 종료 프록시 뒤에서는 uvicorn을 `--proxy-headers --forwarded-allow-ips=...` 옵션으로 실행하세요. 그러지 않으면 리디렉션이 `http://`를 가리키고 클라이언트가 이를 거부합니다. * 2026-07-28에는 세션이 없고, 로드 밸런서가 스티키로 붙들 대상도 없습니다. `stateless_http=True`는 레거시 전용 설정입니다. 최신 요청은 이 플래그를 읽기도 전에 라우팅되고 응답되기 때문입니다. * 기본 `requestState` 키는 프로세스마다 만들어지는 `os.urandom(32)`입니다. 다른 워커에 도달한 다중 왕복 재시도는 `-32602` *"Invalid or expired requestState"*로 실패합니다. * 해결책은 `RequestStateSecurity(keys=[...])`를 쓰는 것, **그리고** 모든 인스턴스에 같은 서버 이름을 쓰는 것입니다. 이름은 토큰의 기본 audience 클레임입니다. 같은 키, 같은 이름. diff --git a/i18n/ko/pages/run/index.md b/i18n/ko/pages/run/index.md index a2a69a98..32590892 100644 --- a/i18n/ko/pages/run/index.md +++ b/i18n/ko/pages/run/index.md @@ -1,6 +1,6 @@ --- translation: - sections: [fea8d769ff9edeba, ce8e2ad42f29ef71, 0d705efb19cf99c2, 2fd7cf825e6d2b2c, 9adc400e8c88e854, 318893ad8e2e9924, 6b63ab96b34476c0] + sections: [fea8d769ff9edeba, ce8e2ad42f29ef71, 0d705efb19cf99c2, 9fd2357154a5b7e7, 9adc400e8c88e854, 318893ad8e2e9924, 6b63ab96b34476c0] tool: 1 --- # 서버 실행하기 {#running-your-server} @@ -75,6 +75,11 @@ Inspector는 실제 호스트가 하는 일을 그대로 합니다. `server.py` * `max_request_body_size`: 허용되는 요청 본문의 최대 크기(바이트). 기본값은 4MiB이며, 더 큰 요청은 파싱이나 세션 생성 전에 HTTP 413을 받습니다. 정상적인 MCP 메시지가 이 크기를 넘을 때만 올리세요. +* `session_idle_timeout`: 진행 중인 것이 아무것도 없는 상태로 레거시 세션이 머물 수 있는 시간(초)이며, 이 시간이 지나면 + 서버가 세션을 닫습니다. 기본값은 1800입니다. `None`이면 비활성화됩니다. + [세션 수명과 제한](legacy-clients.md#session-lifetime-and-limits)을 참고하세요. +* `max_sessions`: 한 프로세스가 동시에 유지하는 레거시 세션의 수. 기본값은 10 000입니다. `None`이면 + 제한이 없어집니다. 같은 섹션에서 다룹니다. * `event_store`, `retry_interval`, `transport_security`: 재개 가능성과 DNS 리바인딩 보호. localhost가 아닌 곳에 배포하기 전까지는 미뤄도 됩니다. `transport_security`는 **[배포와 확장](deploy.md)**에서 다룹니다. !!! warning diff --git a/i18n/ko/pages/run/legacy-clients.md b/i18n/ko/pages/run/legacy-clients.md index b7624924..6ec6aafc 100644 --- a/i18n/ko/pages/run/legacy-clients.md +++ b/i18n/ko/pages/run/legacy-clients.md @@ -1,6 +1,6 @@ --- translation: - sections: [3d1663c18edc824c, d4fd37009a13f03d, af9f398a5a8b679a, 470c2dd144294d69, 8e45827e6d24e8c8, 91dfd0ce98ebb03c] + sections: [3d1663c18edc824c, 90956965ae6a1ca1, af9f398a5a8b679a, 5ce83b1f9d88da62, 0d9b5d13fffc94e5, 8e45827e6d24e8c8, 91dfd0ce98ebb03c] tool: 1 --- # 레거시 클라이언트 지원 {#serving-legacy-clients} @@ -21,15 +21,25 @@ SDK는 모든 요청을 `MCP-Protocol-Version` 헤더에 따라 라우팅합니 ## 하나의 핸들러, 두 시대 {#one-handler-both-eras} -다음은 사용자에게 무언가를 물어봐야 하는 도구와, 그 도구를 호출하는 두 시대의 클라이언트입니다. +다음은 사용자에게 무언가를 물어봐야 하는 도구입니다. -```python title="server.py" hl_lines="24 37-38" +```python title="server.py" hl_lines="21" --8<-- "docs_src/legacy_clients/tutorial001.py" ``` `reserve`에는 모델이 제공하지 않은 정보가 하나 필요합니다. 몇 권인지입니다. `Annotated[..., Resolve(ask_quantity)]`는 도구가 이를 선언하는 방법입니다(자세한 내용은 **[의존성](../handlers/dependencies.md)**에서 확인하세요). `reserve` 안에는 버전을 명시하거나, 기능을 확인하거나, 분기하는 코드가 전혀 없습니다. -두 클라이언트는 같은 `mcp` 객체에 **동시에** 열려 있습니다. `mode="legacy"`는 `initialize` 핸드셰이크를 실행합니다. 2026 이전 클라이언트가 여는 바로 그 연결입니다. 다른 하나는 기본값을 사용해 `2026-07-28` 버전으로 연결됩니다. +HTTP로 띄우세요. 다음은 이 도구를 호출하는 두 시대의 클라이언트입니다. + +```console +uv run mcp run server.py --transport streamable-http +``` + +```python title="client.py" hl_lines="14-15" +--8<-- "docs_src/legacy_clients/tutorial001_client.py" +``` + +두 클라이언트는 실행 중인 같은 서버를 상대로 **동시에** 열려 있습니다. `mode="legacy"`는 `initialize` 핸드셰이크를 실행합니다. 2026 이전 클라이언트가 여는 바로 그 연결입니다. 다른 하나는 기본값을 사용해 `2026-07-28` 버전으로 연결됩니다. 두 번째 터미널에서 `python client.py`를 실행하세요. ```text 2025-11-25 {'result': "Reserved 2 of 'Dune'."} @@ -61,6 +71,31 @@ SDK는 모든 요청을 `MCP-Protocol-Version` 헤더에 따라 라우팅합니 세션에 다시 연결하는 클라이언트에게 놓친 SSE 이벤트를 재생하는 것)입니다. 다른 프로세스에서 세션에 도달할 수 있게 해 주는 일은 결코 없습니다. +## 세션 수명과 제한 {#session-lifetime-and-limits} + +레거시 세션은 영원히 살지 않으며, 한 프로세스가 무한히 많은 세션을 보유하지도 않습니다. 이를 제어하는 설정이 두 가지 있습니다. 둘 다 `run()`, `streamable_http_app()`, `Server.streamable_http_app()`의 키워드 인자입니다. 현대(`2026-07-28`) 연결과 `stateless_http=True`에는 세션이 없으므로 두 설정 모두 적용되지 않습니다. + +| 설정 | 기본값 | 하는 일 | 클라이언트가 보는 것 | 끄는 방법 | +|---|---|---|---|---| +| `session_idle_timeout` | `1800`(30분) | 그 시간 동안 진행 중인 것이 아무것도 없었던 세션을 닫습니다. | `404 Session not found`. 다시 `initialize`해야 합니다. | `None` | +| `max_sessions` | `10_000` | 그 수를 넘는 세션은 열기를 거부합니다. 기존 세션은 건드리지 않으며 아무것도 쫓아내지 않습니다. | JSON-RPC 코드 `-32603`과 함께 `503 Too many open sessions`. | `None` | + +"진행 중"으로 간주되는 것은 다음과 같습니다. + +* 열린 `GET` 스트림. SDK 클라이언트는 하나를 열어 두므로, 연결된 클라이언트의 세션은 만료되지 않습니다. +* 아직 응답 중인 요청. 타임아웃보다 오래 실행되는 도구 호출은 중단되지 않으며, 카운트다운은 호출이 끝난 뒤에야 시작됩니다. +* 그 밖에는 없습니다. 요청 사이에는 시계가 돌아갑니다. 세션에 들어오는 요청은 무엇이든 시계를 다시 시작하며, `ping`도 포함됩니다. 세션이 한 번 만료되면 무엇으로도 되살릴 수 없습니다. + +`DELETE`로 세션을 끝내는 클라이언트는 세션을 즉시 해제합니다. 여는 요청이 거부된 클라이언트도 마찬가지입니다. + +```python +mcp.run(transport="streamable-http", session_idle_timeout=None, max_sessions=50_000) +``` + +두 이벤트 모두 서버 로그에 나타납니다. 만료는 `INFO` 수준의 `Session idle timeout`입니다. 열기 거부는 `WARNING` 수준의 `Refusing to open a new session: sessions are already open`입니다. + +제한은 프로세스별입니다. 워커가 넷이면 상한은 `max_sessions`의 네 배이며, 각 워커는 자기 세션을 스스로 만료시킵니다. + ## 유일한 옵션: `stateless_http` {#the-one-knob-stateless_http} 스티키 라우팅이 치르기 싫은 비용이라면, 바꿀 수 있는 것은 정확히 하나입니다. @@ -85,7 +120,7 @@ SDK는 모든 요청을 `MCP-Protocol-Version` 헤더에 따라 라우팅합니 !!! check 일부러 잘못된 설정을 해 보세요. `reserve`는 방금 두 클라이언트를 모두 지원한 바로 그 도구입니다. - `stateless_http=True`로 배포하고, 같은 두 클라이언트를 HTTP로 연결한 뒤, 각각에서 호출해 보세요. + `stateless_http=True`로 배포하고, 같은 두 클라이언트를 연결한 뒤, 각각에서 호출해 보세요. 현대 클라이언트는 여전히 `Reserved 2 of 'Dune'.`을 받습니다. 현대 경로는 바뀌지 않았습니다. diff --git a/i18n/ko/pages/troubleshooting.md b/i18n/ko/pages/troubleshooting.md index caaf97c4..7a7bed2c 100644 --- a/i18n/ko/pages/troubleshooting.md +++ b/i18n/ko/pages/troubleshooting.md @@ -1,6 +1,6 @@ --- translation: - sections: [2efaecdef109a5c5, fcacd3e66b8635a4, 25323d737dcf0261, 8a6e351ec756904d, 137454d469c867f5, 6392596bd6df54f0, 41126fa9c4fe432f, 480b6d7897e30ab4, d83bb682e708dde0, ebbed3449c499db4, 323ef84f6b4bebde, 30fd31be74169d9a, 656943c6cb567218, c2dc3b1007d2e987, 7cf5386b997d04e9, 0b59feed8384456e, 0cba47bae78d04eb, e4355f4c7cf4fb2e] + sections: [3d58228e81b99543, 170514ce901c4139, 17d61fad0a50d62b, 8a6e351ec756904d, 137454d469c867f5, 6392596bd6df54f0, 41126fa9c4fe432f, 480b6d7897e30ab4, d83bb682e708dde0, ebbed3449c499db4, 525cdf1755e29d4c, 30fd31be74169d9a, d2e88333d4f7841f, c2dc3b1007d2e987, d6eabf60cc366341, f798e815252852c2, 0cba47bae78d04eb, 2c218ba829abf74e] tool: 1 --- # 문제 해결 {#troubleshooting} @@ -13,6 +13,12 @@ translation: --8<-- "docs_src/troubleshooting/tutorial001.py" ``` +해당 항목은 `http://localhost:8000/mcp`로 이 서버에 접속하므로, HTTP로 실행한 채로 두세요. + +```console +uv run mcp run server.py --transport streamable-http +``` + 이 페이지에서 인용하는 오류는 모두 실제 오류입니다. SDK 자체의 테스트 스위트가 하나하나 전부 재현합니다. ## `ExceptionGroup: unhandled errors in a TaskGroup (1 sub-exception)` {#exceptiongroup-unhandled-errors-in-a-taskgroup-1-sub-exception} @@ -23,7 +29,7 @@ translation: ```python async def main() -> None: - async with Client(mcp) as client: + async with Client("http://localhost:8000/mcp") as client: await client.read_resource("weather://Atlantis") ``` @@ -42,14 +48,14 @@ async def main() -> None: +------------------------------------ ``` -이에 대해 할 일은 두 가지입니다. +여기서 할 일은 두 가지입니다. 1. **맨 아래를 읽으세요.** `MCPError: No forecast for 'Atlantis'.`가 실패의 원인입니다. 이 페이지에서 찾아야 할 것은 바로 **이** 텍스트입니다. 2. **블록 안에서 잡으세요.** `ExceptionGroup`은 예외가 `async with`를 **벗어날** 때만 나타납니다. 안에서 잡으면 같은 실패가 그룹 없이 평범한 `MCPError`로 나타납니다. ```python async def main() -> None: - async with Client(mcp) as client: + async with Client("http://localhost:8000/mcp") as client: try: await client.read_resource("weather://Atlantis") except MCPError as e: @@ -67,7 +73,7 @@ async def main() -> None: ```python async def main() -> None: - client = Client(mcp) + client = Client("http://localhost:8000/mcp") tools = await client.list_tools() # RuntimeError ``` @@ -75,7 +81,7 @@ async def main() -> None: ```python async def main() -> None: - async with Client(mcp) as client: + async with Client("http://localhost:8000/mcp") as client: tools = await client.list_tools() ``` @@ -251,7 +257,7 @@ app = Starlette(routes=[Mount("/", app=mcp.streamable_http_app())], lifespan=lif ## `MCPError: Session not found` {#mcperror-session-not-found} -서버가 클라이언트가 보낸 `Mcp-Session-Id`를 알아보지 못하는 경우이며, 거의 언제나 서버가 **재시작**되었기(또는 다른 인스턴스로 라우팅되었기) 때문입니다. 세션은 해당 프로세스 하나의 메모리에만 존재합니다. +서버가 클라이언트가 보낸 `Mcp-Session-Id`를 알아보지 못하는 경우입니다. 서버가 **재시작**되었거나(또는 다른 인스턴스로 라우팅되었거나), `session_idle_timeout` 동안 진행 중인 요청이 없어서 세션이 **만료**된 것입니다. 이 값은 기본적으로 30분입니다. [세션 수명과 제한](run/legacy-clients.md#session-lifetime-and-limits)을 참고하세요. 세션은 해당 프로세스 하나의 메모리에만 존재합니다. 찾아야 할 서버 버그는 없습니다. HTTP 응답은 본문이 **실제로** JSON-RPC인 `404`이므로, 위의 `421`과 달리 Python `Client`가 이번에는 그대로 보여 줍니다. @@ -261,9 +267,9 @@ app = Starlette(routes=[Mount("/", app=mcp.streamable_http_app())], lifespan=lif 해결책은 다시 연결하는 것입니다. `async with Client(...)` 블록을 벗어나 새 블록에 진입하면 새 세션을 협상합니다. 오래 실행되는 클라이언트라면, 호출을 감싸 `MCPError`를 잡은 뒤 이 메시지가 나오면 죽은 세션 안에서 재시도하지 말고 다시 연결해야 한다는 뜻입니다. -재시작 **없이** 이 문제가 발생한다면, 스티키 세션 없이 워커를 둘 이상 실행하고 있는 것입니다. 각 워커가 자기만의 세션 테이블을 가지므로, 엉뚱한 워커로 라우팅된 요청이 여기에 도달합니다. 이 이야기와 두 가지 해결책(스티키 라우팅 또는 `stateless_http=True`)은 **[배포와 확장](run/deploy.md)**과 **[레거시 클라이언트 지원](run/legacy-clients.md)**에서 다룹니다. +재시작도 **없었고** 클라이언트가 그렇게 오래 조용히 있지도 않았는데 이 문제가 발생한다면, 스티키 세션 없이 워커를 둘 이상 실행하고 있는 것입니다. 각 워커가 자기만의 세션 테이블을 가지므로, 엉뚱한 워커로 라우팅된 요청이 여기에 도달합니다. 이 이야기와 두 가지 해결책(스티키 라우팅 또는 `stateless_http=True`)은 **[배포와 확장](run/deploy.md)**과 **[레거시 클라이언트 지원](run/legacy-clients.md)**에서 다룹니다. -서버 운영자 쪽에서 대응하는 로그 줄은 `Rejected request with unknown or expired session ID: `입니다. `INFO` 수준으로 기록되므로 일반적인 `WARNING` 임계값에서는 보이지 않습니다. 배포 직후 이 줄이 한꺼번에 쏟아지는 것은 정상입니다. 연결되어 있던 모든 클라이언트가 다시 연결하는 중이기 때문입니다. +서버 운영자 쪽에서 대응하는 로그 줄은 `Rejected request with unknown or expired session ID: `입니다. `INFO` 수준으로 기록되므로 일반적인 `WARNING` 임계값에서는 보이지 않습니다. 배포 직후 이 줄이 한꺼번에 쏟아지는 것은 정상입니다. 연결되어 있던 모든 클라이언트가 다시 연결하는 중이기 때문입니다. 세션이 만료된 경우라면 그 줄 앞에 `Session idle timeout`이 먼저 찍히며, 역시 `INFO` 수준입니다. ## `MCPError: Method not found` {#mcperror-method-not-found} @@ -275,7 +281,13 @@ app = Starlette(routes=[Mount("/", app=mcp.streamable_http_app())], lifespan=lif 서버가 사용자에게 무언가를 물어보려 하는데, 이 클라이언트가 물어볼 수 있다고 밝힌 적이 없는 경우입니다. -엘리시테이션(elicitation) 리졸버는 연결된 클라이언트가 폼 엘리시테이션을 선언하지 않았으면 처음부터 거부하며, `e.error.data`가 정확히 무엇이 빠졌는지 알려 줍니다. +이 Bistro 서버는 예약하기 전에 리졸버를 통해 먼저 물어봅니다. + +```python title="server.py" hl_lines="15-17 21" +--8<-- "docs_src/troubleshooting/tutorial007.py" +``` + +Weather 서버 대신 이 서버를 띄우고, `elicitation_callback`을 전달하지 않은 클라이언트에서 `book_table`을 호출하세요. 연결된 클라이언트가 폼 엘리시테이션(elicitation)을 선언한 적이 없으므로 리졸버는 처음부터 거부하며, `e.error.data`가 정확히 무엇이 빠졌는지 알려 줍니다. ```json { @@ -289,7 +301,7 @@ app = Starlette(routes=[Mount("/", app=mcp.streamable_http_app())], lifespan=lif ```python async def main() -> None: - async with Client(mcp, elicitation_callback=handle_elicitation) as client: + async with Client("http://localhost:8000/mcp", elicitation_callback=handle_elicitation) as client: result = await client.call_tool("book_table", {"date": "Friday"}) ``` @@ -314,14 +326,14 @@ async def main() -> None: 핸들러가 요청 도중에 클라이언트에 손을 뻗으려 했는데, 그 호출에 서버의 요청을 실어 나를 채널이 없는 연결이었던 경우입니다. 호출을 이런 상황에 놓는 서버 구성은 세 가지입니다. -**`2026-07-28` 연결. 트랜스포트와 무관하게 항상.** 최신 프로토콜에는 서버가 시작하는 요청이 아예 없으므로, 서버는 무엇을 보내기도 전에 거부합니다. 도구 안에서 `ctx.elicit()`을 호출하는 것이 이 오류를 만나는 전형적인 길이며(`Client(server)`는 따로 요청하지 않아도 `2026-07-28`을 협상하므로, 첫 인메모리 테스트에서 바로 만납니다), `elicitation_callback=` 인자를 전달해도 달라지는 것은 없습니다. 클라이언트가 답할 요청 자체가 도달하지 않기 때문입니다. +**`2026-07-28` 연결. 트랜스포트와 무관하게 항상.** 최신 프로토콜에는 서버가 시작하는 요청이 아예 없으므로, 서버는 무엇을 보내기도 전에 거부합니다. 도구 안에서 `ctx.elicit()`을 호출하는 것이 이 오류를 만나는 전형적인 길이며, `Client(mcp)`는 따로 요청하지 않아도 `2026-07-28`을 협상하므로 대개 그 도구의 첫 인메모리 **[테스트](get-started/testing.md)**에서 바로 만납니다. `elicitation_callback=` 인자를 전달해도 달라지는 것은 없습니다. 클라이언트가 답할 요청 자체가 도달하지 않기 때문입니다. ```python title="server.py" hl_lines="16" --8<-- "docs_src/troubleshooting/tutorial006.py" ``` ```python -async def main() -> None: +async def test_book_table() -> None: async with Client(mcp) as client: await client.call_tool("book_table", {"date": "Friday"}) ``` @@ -363,7 +375,7 @@ mcp.shared.exceptions.MCPError: Cannot send 'elicitation/create': this transport ```python async def main() -> None: - async with Client(mcp) as client: + async with Client("http://localhost:8000/mcp") as client: await client.call_tool("forecast", {"city": "London"}, request_state="round-1-from-worker-a") ``` @@ -416,7 +428,7 @@ mcp = MCPServer("Weather", request_state_security=RequestStateSecurity(keys=[key * 서버 로그의 `Tool already exists:`는 이름이 같은 두 도구가 하나로 합쳐졌다는 유일한 신호입니다. * 421 하나에 표기는 세 가지입니다. `Server returned an error response`(Python `Client`), `421 Misdirected Request` / `Invalid Host header`(그 밖의 모든 곳), `Invalid Host header: `(서버 로그). 해결책은 `transport_security=TransportSecuritySettings(allowed_hosts=[...])`입니다. * `Task group is not initialized` -> 마운트된 앱에서 호스트 lifespan이 `mcp.session_manager.run()`에 진입하지 않은 경우입니다. -* `Session not found` -> 서버가 재시작되었습니다. 다시 연결하세요. +* `Session not found` -> 서버가 재시작되었거나 세션이 만료되었습니다(`session_idle_timeout`). 다시 연결하세요. * `Cannot send 'elicitation/create': ... no back-channel ...` -> `ctx.elicit()`에는 서버에서 클라이언트로 가는 채널이 필요합니다. `2026-07-28` 연결에는 그런 채널이 아예 없고, `stateless_http=True`는 레거시 채널을 없애며, `json_response=True`는 요청 범위 채널을 없앱니다. 리졸버를 사용하세요(레거시 클라이언트라면 채널을 유지하는 서버도 필요합니다). 이웃인 `Method not found`는 상대편 프로토콜 리비전에 없는 메서드를 요청한 경우입니다. * `Client did not declare the form elicitation capability ...` 및 `Elicitation not supported` -> 클라이언트에 `elicitation_callback=` 인자가 빠져 있습니다. * `Invalid or expired requestState`는 와이어에서 이유를 절대 말하지 않습니다. 서버 로그가 말해 주며, `unknown key`는 워커 간에 `RequestStateSecurity(keys=[...])` 설정을 공유하라는 뜻입니다. diff --git a/i18n/ko/pages/whats-new.md b/i18n/ko/pages/whats-new.md index 84077c3f..6c50e98d 100644 --- a/i18n/ko/pages/whats-new.md +++ b/i18n/ko/pages/whats-new.md @@ -1,6 +1,6 @@ --- translation: - sections: [cfe01c0c5863dfa2, 1c58c5cfcc37d455, a7392996acf1ad8f, 875eb2889263424e] + sections: [cfe01c0c5863dfa2, 0dbb68d8b210177b, 80cf193023af3ed4, 875eb2889263424e] tool: 1 --- # v2에서 달라진 점 {#whats-new-in-v2} @@ -42,11 +42,11 @@ mcp = MCPServer("Demo") # v1: FastMCP("Demo") v1은 세 겹으로 중첩된 계층을 건네주었습니다. 원시 스트림을 내놓는 트랜스포트 컨텍스트 매니저, 이를 감싸는 `ClientSession`, 그리고 직접 호출해야 하는 `await session.initialize()`입니다. v2에는 객체가 하나뿐입니다. -```python title="client.py" hl_lines="14-18" ---8<-- "docs_src/client/tutorial001.py" +```python title="client.py" hl_lines="7-11" +--8<-- "docs_src/client/tutorial001_client.py" ``` -`Client`는 서버 객체(트랜스포트 없이 인메모리로 동작하며, 테스트에 쓰는 방식입니다), URL(Streamable HTTP), `StdioServerParameters`(stdio 하위 프로세스), 또는 `sse_client(...)` 같은 그 밖의 임의의 트랜스포트 컨텍스트 매니저를 받습니다. `async with`에 진입하면 서버가 어느 시대의 프로토콜을 말하든 연결을 맺고 프로토콜 버전을 협상합니다. 그 뒤에는 `client.server_capabilities`와 `client.protocol_version`이 그냥 준비되어 있고, 서버가 자신을 식별하는 경우에는 `client.server_info`도 마찬가지입니다(2026 시대에는 식별 정보가 선택 사항이므로 이제 타입은 `Implementation | None`입니다). v1에서 등록한 샘플링 및 엘리시테이션 콜백은 여전히 동작하며(콜백 본문에는 이 페이지의 다른 모든 것과 마찬가지로 snake_case 속성 이름 변경이 적용됩니다), 이제 2026 방식의 결과 속 요청(아래 참고)에도 응답하고, 한 번에 하나씩이 아니라 동시에 실행됩니다. 저수준 인터페이스를 원하는 경우를 위해 `ClientSession`은 여전히 그 아래에 있으며 `client.session`으로 얻을 수 있습니다. 다만 이 클래스 역시 바뀌었으므로(새 디스패처 엔진 위에서 실행되고, 자체 시그니처 일부도 변경되었습니다) 아래 계층으로 내려가기 전에 **[마이그레이션 가이드](migration.md#clientsession-now-runs-on-jsonrpcdispatcher-basesession-removed)**를 읽어 보세요. +`Client`는 URL(Streamable HTTP), `StdioServerParameters`(stdio 하위 프로세스), `sse_client(...)` 같은 그 밖의 임의의 트랜스포트 컨텍스트 매니저, 또는 테스트에서는 서버 객체 자체(트랜스포트 없이 인메모리로 동작합니다)를 받습니다. `async with`에 진입하면 서버가 어느 시대의 프로토콜을 말하든 연결을 맺고 프로토콜 버전을 협상합니다. 그 뒤에는 `client.server_capabilities`와 `client.protocol_version`이 그냥 준비되어 있고, 서버가 자신을 식별하는 경우에는 `client.server_info`도 마찬가지입니다(2026 시대에는 식별 정보가 선택 사항이므로 이제 타입은 `Implementation | None`입니다). v1에서 등록한 샘플링 및 엘리시테이션 콜백은 여전히 동작하며(콜백 본문에는 이 페이지의 다른 모든 것과 마찬가지로 snake_case 속성 이름 변경이 적용됩니다), 이제 2026 방식의 결과 속 요청(아래 참고)에도 응답하고, 한 번에 하나씩이 아니라 동시에 실행됩니다. 저수준 인터페이스를 원하는 경우를 위해 `ClientSession`은 여전히 그 아래에 있으며 `client.session`으로 얻을 수 있습니다. 다만 이 클래스 역시 바뀌었으므로(새 디스패처 엔진 위에서 실행되고, 자체 시그니처 일부도 변경되었습니다) 아래 계층으로 내려가기 전에 **[마이그레이션 가이드](migration.md#clientsession-now-runs-on-jsonrpcdispatcher-basesession-removed)**를 읽어 보세요. **[클라이언트](client/index.md)**에서 소개하고, **[클라이언트 트랜스포트](client/transports.md)**에서 네 가지 연결 형태를, **[클라이언트 콜백](client/callbacks.md)**에서 콜백 자체를 다루며, **[테스트](get-started/testing.md)**에서는 v1의 `create_connected_server_and_client_session()` 헬퍼를 대체하는 인메모리 패턴을 보여 줍니다. @@ -171,11 +171,15 @@ Streamable HTTP에서는 2026 경로에 `Mcp-Session-Id`가 없으며, 운영 대체 방식은 호출의 방향을 뒤집습니다. 사용자로부터 무언가가 필요한 도구는 질문을 **반환**하고(`InputRequiredResult`), 클라이언트는 늘 갖고 있던 것과 같은 콜백으로 질문에 답하며, 답이 첨부된 채로 호출이 재시도됩니다. 이 루프는 `Client`가 대신 돌려 줍니다. 서버에서 결과를 직접 만드는 일은 드뭅니다. **[의존성](handlers/dependencies.md)**이 대신 해 주기 때문입니다. 매개변수에 `Resolve(ask_quantity)` 어노테이션을 달면(`ask_quantity`는 직접 작성하는 평범한 함수입니다), SDK가 연결이 지원하는 메커니즘, 즉 레거시 세션에서는 실시간 엘리시테이션 요청, 2026에서는 다중 왕복을 통해 질문합니다. 도구 본문 하나로 두 시대를 지원합니다. -```python title="dual_era.py" hl_lines="24 37-38" +```python title="server.py" hl_lines="21" --8<-- "docs_src/legacy_clients/tutorial001.py" ``` -이 파일 하나에 핵심이 모두 담겨 있습니다. 서버 하나, `Resolve` 기반 도구 하나, 그리고 레거시 클라이언트와 최신 클라이언트가 모두 인메모리로 답을 받습니다. 메커니즘(SDK가 대신 봉인하고 검증하는 `request_state` 포함)은 **[다중 왕복 요청](handlers/multi-round-trip.md)**에서 설명하고, 질문하는 쪽은 **[엘리시테이션](handlers/elicitation.md)**에서 다룹니다. +```python title="client.py" hl_lines="14-15" +--8<-- "docs_src/legacy_clients/tutorial001_client.py" +``` + +이 두 파일에 핵심이 모두 담겨 있습니다. 서버 하나, `Resolve` 기반 도구 하나, 그리고 레거시 클라이언트와 최신 클라이언트가 모두 실행 중인 같은 서버에서 답을 받습니다(**[레거시 클라이언트 지원](run/legacy-clients.md)**에서 이 두 파일을 하나씩 짚어 봅니다). 메커니즘(SDK가 대신 봉인하고 검증하는 `request_state` 포함)은 **[다중 왕복 요청](handlers/multi-round-trip.md)**에서 설명하고, 질문하는 쪽은 **[엘리시테이션](handlers/elicitation.md)**에서 다룹니다. !!! warning "포팅한 v1 서버의 동작이 바뀌는 유일한 지점입니다" 가장 먼저 부딪히는 것은 직접 작성한 테스트입니다. `Client(mcp)`는 기본적으로 v2 서버와 2026-07-28을 diff --git a/i18n/pt/pages/advanced/apps.md b/i18n/pt/pages/advanced/apps.md index 06a5c15d..49a41266 100644 --- a/i18n/pt/pages/advanced/apps.md +++ b/i18n/pt/pages/advanced/apps.md @@ -1,6 +1,6 @@ --- translation: - sections: [0355618e5f4d5fe4, 1821eaf50f2d0b64, 82e0b28ebd3abf5a, 8ac39614c094f2d0, dab6ff945501ab2a, bd5565c3b2d4f959, 96819ce3d63a0487] + sections: [0355618e5f4d5fe4, 0fefa31fb7c7b585, 5c53e7487c9c70cc, 8ac39614c094f2d0, dab6ff945501ab2a, bd5565c3b2d4f959, 96819ce3d63a0487] tool: 1 --- # MCP Apps {#mcp-apps} @@ -25,7 +25,7 @@ e depois volte aqui. ## Um relógio com uma cara {#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" ``` @@ -56,15 +56,36 @@ O modelo lê `content`; o iframe é para humanos. Um host com suporte a UI ainda o resultado em texto para o modelo, e um cliente só de texto recebe *apenas* isso. Então o padrão canônico é uma ferramenta, duas respostas. Olhe `get_time` de novo: -```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)` é `True` somente quando o cliente declarou a extensão `io.modelcontextprotocol/ui` **e** listou `text/html;profile=mcp-app` nas suas configurações `mimeTypes`. O campo é obrigatório, então um cliente que o omite -não conta. É exatamente isso que `main()` no mesmo arquivo declara: a -metade cliente da negociação, e a resposta rica volta. +não conta. Aqui está a metade cliente da negociação: + +```python title="client.py" hl_lines="8 12" +--8<-- "docs_src/apps/tutorial001_client.py" +``` + +Sirva `server.py` por HTTP e depois execute o cliente em um segundo terminal: + +```console +uv run mcp run server.py --transport streamable-http +``` + +```console +python client.py +``` + +```text +2026-06-26T12:00:00Z +``` + +A resposta rica voltou. Tire `extensions=[APPS_SUPPORT]` da chamada a `Client` +e o mesmo programa imprime `The time is 2026-06-26T12:00:00Z.` no lugar, que é +tudo o que um cliente só de texto chega a ver. !!! warning Nunca retorne um placeholder como `"[Rendered UI]"` como único conteúdo. Se o diff --git a/i18n/pt/pages/advanced/extensions.md b/i18n/pt/pages/advanced/extensions.md index c9ceb86f..51debad3 100644 --- a/i18n/pt/pages/advanced/extensions.md +++ b/i18n/pt/pages/advanced/extensions.md @@ -1,6 +1,6 @@ --- translation: - sections: [05891e7cc1938a13, b3c01a6af28c51ee, 7ffc91f5e38bdfe0, 717d3f235a8333a7, f471a13b2fe5d737, ed6af2df4b656dff] + sections: [05891e7cc1938a13, b3c01a6af28c51ee, 6eba6ce094f4417e, 125f28588c85dd7b, ddd8969b698ca11c, ed6af2df4b656dff] tool: 1 --- # Extensões {#extensions} @@ -49,7 +49,7 @@ Use como prefixo um domínio que você controla. `io.modelcontextprotocol/*` é A menor extensão útil é uma ferramenta e um mapa de configurações: -```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" ``` @@ -57,17 +57,23 @@ A menor extensão útil é uma ferramenta e um mapa de configurações: * `settings()` é o valor anunciado em `capabilities.extensions["com.example/stamps"]`. Retorne `{}` (o padrão) para anunciar a extensão sem configurações. * A extensão nunca recebe o servidor. Ela declara contribuições como dados; o `MCPServer` as consome. Não existe um `self.server` para modificar. -E `main()` é a prova, um cliente em memória direto contra `mcp`: +Sirva-a por HTTP, e um cliente é a prova: -```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" +``` + +Todo `server.py` desta página é servido com esse comando, e todo `client.py` roda ao lado dele com `python client.py` em um segundo terminal. + ### Servindo seus próprios métodos {#serving-your-own-methods} Uma extensão pode registrar **novos métodos de requisição**: seus próprios verbos, servidos ao lado dos da especificação: -```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" ``` @@ -83,14 +89,15 @@ Os métodos são **estritamente aditivos**. O SDK impõe isso na construção, n ### O lado do cliente {#the-client-side} -O `main()` do mesmo arquivo é a história inteira do cliente, com as duas metades: +O cliente é um programa à parte, e carrega as duas metades da história do cliente: -```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)])` declara a extensão. As declarações viram `ClientCapabilities.extensions`: em uma conexão 2026-07-28 o mapa viaja no envelope `_meta` de cada requisição, então o servidor o vê em **toda** requisição; em uma conexão legada ele vai no handshake `initialize`. O código do servidor não se importa com qual: `require_client_extension(ctx, ...)` e `ctx.session.check_client_capability(...)` leem a fonte certa nos dois caminhos. * Métodos de fornecedor descem uma camada para `client.session.send_request(...)`; `Client` só ganha métodos de primeira classe para verbos da especificação. `send_request` aceita qualquer subclasse de `Request`, então a requisição do fornecedor passa como está. +* `SearchRequest` e os dois modelos que ela carrega são o contrato de protocolo da extensão, então o cliente os declara por conta própria. Uma extensão publicada os distribuiria em um pacote que os dois lados importam. ### Interceptando `tools/call` {#intercepting-toolscall} @@ -109,12 +116,18 @@ O hook envolve `tools/call` e nada mais. Para preocupações que valem para toda ## Usando uma extensão de cliente {#using-a-client-extension} -Uma **extensão de cliente** é o mesmo contrato visto do lado consumidor: um pacote de comportamento do lado do cliente reunido sob um único identificador. Passe as instâncias para `Client(extensions=[...])` e chame as ferramentas normalmente: +Uma **extensão de cliente** é o mesmo contrato visto do lado consumidor: um pacote de comportamento do lado do cliente reunido sob um único identificador. Aqui o servidor responde a `buy` com um recibo a resgatar em vez da mercadoria, e só para um cliente que declarou a extensão: -```python title="client.py" hl_lines="66-68" +```python title="server.py" hl_lines="22-25" --8<-- "docs_src/extensions/tutorial006.py" ``` +No cliente, passe as instâncias para `Client(extensions=[...])` e chame as ferramentas normalmente: + +```python title="client.py" hl_lines="33-35" +--8<-- "docs_src/extensions/tutorial006_client.py" +``` + `call_tool("buy", ...)` retorna um `CallToolResult` comum, como toda outra chamada. O que a extensão mudou: o servidor agora pode responder a `buy` com um **formato de resultado** `receipt` em vez de um resultado final, e `Receipts` o finaliza (aqui, resgatando o recibo com uma chamada seguinte) antes de `call_tool` retornar. Nada muda no ponto da chamada. Tire a extensão e nada disso existe: a barreira do servidor recusa um cliente que não a declarou (erro -32021), e um formato reivindicado vindo de um servidor que pula a barreira falha na validação, exatamente como a especificação exige para um `resultType` não reconhecido. Desligado por padrão, nas duas pontas da conexão. @@ -124,15 +137,15 @@ Para anunciar um identificador **sem** nenhum comportamento do lado do cliente ( ```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")]) ``` ## Escrevendo uma extensão de cliente {#writing-a-client-extension} Herde de `ClientExtension` e sobrescreva apenas o que precisar. Três tipos de contribuição, cada um com um padrão: `settings()`, `claims()` e `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" ``` * O identificador segue a mesma gramática do servidor, validada quando a classe é definida. @@ -153,12 +166,18 @@ Duas regras discretas. As claims ficam ativas apenas em conexões 2026-07-28, e ### Verbos de extensão {#extension-verbs} -Os métodos de requisição próprios de uma extensão não precisam de registro no lado do cliente. Um tipo de requisição de fornecedor herda de `mcp.types.Request` e passa por `client.session.send_request`, como em [Servindo seus próprios métodos](#serving-your-own-methods). Um acréscimo: quando uma chave de params precisa viajar no header `Mcp-Name` (especificações de extensão como tasks exigem isso para seus verbos), o tipo de requisição declara `name_param`: +Os métodos de requisição próprios de uma extensão não precisam de registro no lado do cliente. Um tipo de requisição de fornecedor herda de `mcp.types.Request` e passa por `client.session.send_request`, como em [Servindo seus próprios métodos](#serving-your-own-methods). Considere um servidor cuja extensão serve um único verbo sobre um job nomeado: -```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" ``` +Um acréscimo no cliente: quando uma chave de params precisa viajar no header `Mcp-Name` (especificações de extensão como tasks exigem isso para seus verbos), o tipo de requisição declara `name_param`: + +```python title="client.py" hl_lines="20-23 28-29" +--8<-- "docs_src/extensions/tutorial007_client.py" +``` + A sessão espelha `params["jobId"]` em `Mcp-Name` em todo caminho de envio, e um valor ausente falha de forma explícita em vez de omitir silenciosamente um header obrigatório. ## O que uma extensão não pode fazer {#what-an-extension-cannot-do} diff --git a/i18n/pt/pages/advanced/low-level-server.md b/i18n/pt/pages/advanced/low-level-server.md index 9c9cd540..a75014bb 100644 --- a/i18n/pt/pages/advanced/low-level-server.md +++ b/i18n/pt/pages/advanced/low-level-server.md @@ -1,6 +1,6 @@ --- translation: - sections: [2c79b6338e09b7ac, 7edc43b3fae11314, 1086e77ce561cd7f, a3f71823df5efc31, 9fc7109f72201cae, d50fe7faead8cf68, 7bf25983df655b66, 6330e1f4c6029683, 2f1749c8c133fa1c, 8db7116fc8ddd0ee, ebc33704fbd74262, cd0e9c933350390e] + sections: [2c79b6338e09b7ac, 9d5d10a5f0405d0a, 1086e77ce561cd7f, a3f71823df5efc31, 9fc7109f72201cae, d50fe7faead8cf68, 7bf25983df655b66, 6330e1f4c6029683, 2f1749c8c133fa1c, 8db7116fc8ddd0ee, ebc33704fbd74262, 0fde3bcea081ba3a] tool: 1 --- # O Server de baixo nível {#the-low-level-server} @@ -36,18 +36,22 @@ Três coisas mudaram, e elas são a API de baixo nível inteira: ### Experimente {#try-it} -Não existe Inspector para este aqui: `mcp dev` e `mcp run` só aceitam um `MCPServer`. O `Client` em memória não se importa; ele recebe um `Server` de baixo nível exatamente como recebe um `MCPServer`: +`mcp dev` e `mcp run` só aceitam um `MCPServer`, então este aqui você serve por conta própria. A última linha de `server.py` monta um app ASGI comum a partir dele, e o uvicorn o executa: -```python title="main.py" +```console +uvicorn server:app --port 8000 +``` + +Aponte o Inspector, ou qualquer cliente, para `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) @@ -64,6 +68,8 @@ O mesmo texto que a versão com `@mcp.tool()` produziu. Duas diferenças honesta * `result.structured_content` é `None`. O servidor de alto nível encapsula um `-> str` em `{"result": ...}` para você; aqui ninguém monta o que você não montou. * `list_tools` retorna o schema que **você** digitou, caractere por caractere. A versão de alto nível tinha `"title": "Query"` em cada propriedade e um `"title": "search_booksArguments"` na raiz: artefatos do Pydantic. Aqui embaixo, se está no fio, foi você quem colocou lá. +Em um teste você dispensa o uvicorn e a porta: `Client(server)` recebe um `Server` de baixo nível no mesmo processo exatamente como recebe um `MCPServer`, e **[Testes](../get-started/testing.md)** é esse padrão. + ## Nada é verificado por você {#nothing-is-checked-for-you} O `MCPServer` rejeita um argumento ruim antes mesmo de a sua função executar, validando a chamada contra o schema que ele gerou (**[Ferramentas](../servers/tools.md)**). @@ -208,11 +214,11 @@ Cada um destes é uma ideia para a qual você já tem o vocabulário; cada um te ## Recapitulando {#recap} * O `Server` de baixo nível recebe os seus handlers como **parâmetros do construtor** `on_*`; todo handler é `async (ctx, params) -> result`. -* Você escreve o dict `input_schema` e você monta o `CallToolResult`. Nada é derivado, encapsulado ou validado por você. +* Você escreve o dict `input_schema` e você monta o `CallToolResult`. Nada é derivado, encapsulado ou validado para você. * Uma exceção em um handler é um erro de protocolo `-32603`. Um erro de ferramenta que o modelo consegue ler é um `CallToolResult` com `is_error=True` que **você** retorna. * O `_meta` no resultado é endereçado à aplicação cliente, não ao modelo. * `Server[T]` é genérico no que o seu lifespan produz; `ctx.lifespan_context` é um `T` tipado. * `add_request_handler(method, params_type, handler)` serve qualquer método. `initialize` é reservado. * As capacidades que um `Server` anuncia são derivadas de quais handlers você registrou. -`Client(server)` tratou os dois servidores de forma idêntica porque eles *são* o mesmo protocolo, e essa é justamente a ideia. A próxima camada abaixo nem é uma classe: é **[Middleware](middleware.md)**. +O cliente tratou os dois servidores de forma idêntica porque eles *são* o mesmo protocolo, e essa é justamente a ideia. A próxima camada abaixo nem é uma classe: é **[Middleware](middleware.md)**. diff --git a/i18n/pt/pages/advanced/pagination.md b/i18n/pt/pages/advanced/pagination.md index e0ff9876..e1e1f777 100644 --- a/i18n/pt/pages/advanced/pagination.md +++ b/i18n/pt/pages/advanced/pagination.md @@ -1,6 +1,6 @@ --- translation: - sections: [a9aba7a026c7bd85, ed32bda7ba9ae33a, 7e64cc5646abb91f, 22a0129ee78b3c63, d875373c06d8d2f9] + sections: [a9aba7a026c7bd85, 83e2a08b9d46a398, 9fd8a0aa384b3257, 22a0129ee78b3c63, d875373c06d8d2f9] tool: 1 --- # Paginação {#pagination} @@ -31,9 +31,13 @@ O `@mcp.resource()` não tem nenhum gancho para isso. Para paginar, você mesmo ### Experimente {#try-it} -`Client(server)` se conecta a um `Server` de baixo nível em memória exatamente como se conecta a um `MCPServer`. +O `mcp run` só aceita um `MCPServer`, então este aqui você mesmo serve. A última linha de `server.py` monta um app ASGI comum a partir do `Server`, e o uvicorn executa esse app: -Chame `list_resources()` sem argumentos. Você recebe dez recursos, de `book-1` a `book-10`, e `next_cursor` é a string `"10"`. +```console +uvicorn server:app --port 8000 +``` + +Aponte qualquer cliente (**[O cliente](../client/index.md)**, ou o Inspector) para `http://localhost:8000/mcp` e chame `list_resources()` sem argumentos. Você recebe dez recursos, de `book-1` a `book-10`, e `next_cursor` é a string `"10"`. Devolva-a com `list_resources(cursor="10")` e o primeiro recurso é `book-11`, o novo `next_cursor` é `"20"`. @@ -43,7 +47,7 @@ A décima página volta com `next_cursor` definido como `None`. Pronto. Todo método `list_*` do `Client` (`list_tools`, `list_resources`, `list_resource_templates`, `list_prompts`) aceita a palavra-chave `cursor=`. Esgotar uma lista paginada é um único `while True`: -```python title="client.py" hl_lines="26-32" +```python title="client.py" hl_lines="9-15" --8<-- "docs_src/pagination/tutorial002.py" ``` @@ -51,7 +55,7 @@ Todo método `list_*` do `Client` (`list_tools`, `list_resources`, `list_resourc * Acumule **antes** de olhar para `next_cursor`: a última página também tem recursos. * `next_cursor is None` é a saída. Qualquer outra coisa volta direto para `cursor=`, intocada. -Execute o `main()` dele e ele imprime `100 resources`: dez páginas de dez, costuradas por um loop que nunca soube que havia dez páginas. +Com o uvicorn ainda servindo `server.py`, execute `python client.py` em um segundo terminal. Ele imprime `100 resources`: dez páginas de dez, costuradas por um loop que nunca soube que havia dez páginas. Este é o mesmo loop que **[O cliente](../client/index.md)** mostra para todo verbo `list_*`, e ele não custa nada contra um servidor que não pagina: `next_cursor` é `None` na primeira resposta e o loop roda uma vez. diff --git a/i18n/pt/pages/client/caching.md b/i18n/pt/pages/client/caching.md index e6cfdf83..73b242b3 100644 --- a/i18n/pt/pages/client/caching.md +++ b/i18n/pt/pages/client/caching.md @@ -1,6 +1,6 @@ --- translation: - sections: [9e7b9a1710e5aeba, b74ca4c1d2ddddee, fa8714e61bf90c5a, 04db67a886b7271c, 857690fb8f876800] + sections: [9e7b9a1710e5aeba, 66a2e9acc9101d54, d3d25aa802f5145a, 04db67a886b7271c, 857690fb8f876800] tool: 1 --- # Dicas de cache {#caching-hints} @@ -30,7 +30,7 @@ Por padrão, todo resultado diz `ttlMs: 0, cacheScope: "private"`: obsoleto imed No `Server` de baixo nível, os handlers montam seus resultados à mão, e `ttl_ms` / `cache_scope` são apenas campos nos modelos de resultado. Um handler que os define explicitamente sempre vence o mapa do construtor, campo a campo: -```python title="server.py" hl_lines="10 16" +```python title="server.py" hl_lines="11 17" --8<-- "docs_src/caching/tutorial002.py" ``` @@ -44,10 +44,24 @@ Uma ressalva sobre listas paginadas: o protocolo exige o **mesmo `cacheScope` em Numa sessão 2026-07-28, o `Client` respeita as dicas por você: ele tem um cache de respostas embutido, ligado por padrão. Um resultado que chega carregando um `ttlMs` é armazenado, e uma chamada idêntica dentro desse TTL é servida do cache sem viagem de ida e volta. Um resultado que não carrega *nenhuma* dica não é armazenado: resultados sem dica recebem `CacheConfig.default_ttl_ms`, cujo padrão é `0` (obsoleto imediatamente), então um servidor que não declara nada vê exatamente o mesmo tráfego chamada a chamada de sempre. -```python title="client.py" hl_lines="33 35 38" +Para ver isso acontecer, sirva o `server.py` da seção anterior com o uvicorn (a última linha dele monta o app ASGI). O handler imprime uma linha toda vez que executa de verdade: + +```console +uvicorn server:app --port 8000 +``` + +```python title="client.py" hl_lines="20 23 28" --8<-- "docs_src/caching/tutorial003.py" ``` +Execute `python client.py` em um segundo terminal. Ele imprime as dicas que o primeiro resultado carregava, o `ttlMs` do handler ao lado do `cacheScope` do mapa: + +```text +1000 public +``` + +O terminal do servidor conta o resto da história: entre os logs de requisição do uvicorn, `tools/list served` aparece três vezes. + Quatro chamadas, três buscas. A segunda chamada encontrou uma entrada fresca e nunca chegou ao servidor; avançar o relógio (injetado) além do TTL fez a terceira buscar de novo; a quarta disse `cache_mode="refresh"`. Esse argumento nomeado existe nos cinco verbos com cache (`list_tools`, `list_prompts`, `list_resources`, `list_resource_templates`, `read_resource`): * `"use"` (o padrão) serve uma entrada fresca se houver uma, e armazena a busca se não houver. @@ -56,7 +70,7 @@ Quatro chamadas, três buscas. A segunda chamada encontrou uma entrada fresca e Uma regra fica acima de `"use"`: **chamadas que carregam `meta` sempre chegam ao servidor.** Uma requisição com `meta` definido (um token de progresso, campos de rastreamento) espera uma requisição no fio, então sob `cache_mode="use"` ela é tratada como `"refresh"`: a leitura do cache é pulada, e o resultado buscado ainda substitui a entrada em cache. `"bypass"` e um `"refresh"` explícito se comportam como sempre. -Para desligar o cache por completo, construa com `Client(server, cache=None)`: toda chamada volta a ser uma viagem de ida e volta, e `cache_mode`, embora ainda aceito, não faz nada. +Para desligar o cache por completo, passe `cache=None` ao construir o `Client`: toda chamada volta a ser uma viagem de ida e volta, e `cache_mode`, embora ainda aceito, não faz nada. O escopo também é respeitado automaticamente: entradas `"private"` são indexadas pela *partição* do cache (abaixo), enquanto as `"public"` podem optar por um compartilhamento mais amplo. E **notificações vencem o TTL** para as entradas exatas que nomeiam: uma notificação `list_changed` remove a listagem em cache correspondente, e `resources/updated` remove a leitura em cache armazenada sob exatamente a sua URI, por mais frescas que estivessem. Numa conexão 2026-07-28 essas notificações chegam num stream `subscriptions/listen` que você abre com `client.listen(...)`, e a remoção se completa antes de o seu observador ver o evento; **[Assinaturas](subscriptions.md)** é essa página. diff --git a/i18n/pt/pages/client/callbacks.md b/i18n/pt/pages/client/callbacks.md index 39308a3e..5fa7b266 100644 --- a/i18n/pt/pages/client/callbacks.md +++ b/i18n/pt/pages/client/callbacks.md @@ -1,6 +1,6 @@ --- translation: - sections: [adf3c545b5be46b6, 916cd3ab1c03f461, e9be7a8d0eb0a456, 565890a636288ecf, 6af7e49db9129ec3, 06b0238c174186af, 90c6043be435fcb0] + sections: [adf3c545b5be46b6, 916cd3ab1c03f461, 32ef568335dd95a7, 565890a636288ecf, 6af7e49db9129ec3, 06b0238c174186af, 0abc5ea5cb7ff6b3] tool: 1 --- # Callbacks do cliente {#client-callbacks} @@ -63,7 +63,7 @@ Um `tools/call` seu, um `elicitation/create` de volta do servidor, respondido pe O `mode="legacy"` na chamada `Client(...)` está fazendo trabalho de verdade. Por padrão, `Client(...)` negocia o caminho moderno do protocolo, e esse caminho não tem canal de retorno (back-channel) para requisições do servidor ao cliente: `ctx.elicit` falha antes mesmo de o seu callback rodar. Não é o transporte que decide isso; é o protocolo - negociado, tanto em memória quanto por uma URL. Fixe `mode="legacy"` sempre que o seu cliente tiver + negociado. Fixe `mode="legacy"` sempre que o seu cliente tiver que responder a uma; todos os testes por trás desta página fazem isso. **[Versões do protocolo](../protocol-versions.md)** tem a história completa. Em uma sessão 2026-07-28 o callback não está morto, ele é alimentado de outro jeito: quando uma ferramenta retorna um @@ -151,4 +151,4 @@ Mais dois. Nenhum deles declara nada. * `sampling_callback` e `list_roots_callback` funcionam do mesmo jeito, mas atendem funcionalidades descontinuadas; servidores modernos usam requisições de múltiplas idas e voltas no lugar. * `logging_callback` e `message_handler` recebem notificações. Eles não declaram nada. -O primeiro argumento de `Client(...)` é um objeto de transporte. **[Transportes do cliente](transports.md)** cobre todos os tipos. +O primeiro argumento de `Client(...)` escolhe o transporte. **[Transportes do cliente](transports.md)** cobre todos os tipos. diff --git a/i18n/pt/pages/client/index.md b/i18n/pt/pages/client/index.md index abffbbde..72c13cdf 100644 --- a/i18n/pt/pages/client/index.md +++ b/i18n/pt/pages/client/index.md @@ -1,6 +1,6 @@ --- translation: - sections: [ebef1e7a0df854f4, 8355cfaf1f76c9d5, 8e79141fc2985342, 46bdb07c7537e8a5, 80ce41579825a6fa, 5f0fa90494de8f65, 83d10514eaa62fa5, 9190555aa39a5d28, 84a4c9d8bf14dddb, 927d71cf40b58c30] + sections: [ebef1e7a0df854f4, 7e16449f66e7dfd6, eeb0682f7d2a1079, 5713f0196a34e6e7, 0e844597859e4248, 3a97d9195ddcc92e, 1da08c483e59c141, 84702cc6e0a1fd42, 8dee7a31c86ffc5c, 83a5bce168ef23d7] tool: 1 --- # O cliente {#the-client} @@ -11,13 +11,23 @@ Um **`Client`** é como um programa Python conversa com um servidor MCP. ## Seu primeiro cliente {#your-first-client} -```python title="client.py" hl_lines="14-18" +Um cliente precisa de um servidor com quem conversar. Esta Bookshop é o servidor a que todo trecho desta página se conecta. Salve-o como `server.py` e deixe-o rodando via HTTP: + +```python title="server.py" --8<-- "docs_src/client/tutorial001.py" ``` -O servidor no topo só está ali para você ter algo a que se conectar. O cliente são as cinco linhas destacadas. +```console +uv run mcp run server.py --transport streamable-http +``` -* `Client(mcp)` recebe o **próprio objeto servidor**. Esse é o transporte em memória: sem subprocesso, sem porta, sem HTTP. É assim que todo exemplo nesta página, e todo teste que você escrever, se conecta. +Isso o serve em `http://localhost:8000/mcp`. O cliente é um programa à parte. Salve-o como `client.py` e execute `python client.py` em um segundo terminal: + +```python title="client.py" hl_lines="7-11" +--8<-- "docs_src/client/tutorial001_client.py" +``` + +* `Client("http://localhost:8000/mcp")` recebe uma **URL**, então se conecta via Streamable HTTP ao servidor que você acabou de iniciar. * `async with` é o **ciclo de vida**. Entrar nele conecta e negocia; sair dele desconecta. Não há um par `connect()` / `close()`, e um `Client` não pode ser reutilizado depois que o bloco termina. * Dentro do bloco, os fatos da conexão já estão ali como propriedades comuns. @@ -25,10 +35,10 @@ O servidor no topo só está ali para você ter algo a que se conectar. O client `Client` recebe um argumento posicional e resolve o transporte a partir do tipo dele: -* Uma instância de `MCPServer` (ou do `Server` de baixo nível): conectada **no mesmo processo**. -* Uma string de URL (`Client("http://localhost:8000/mcp")`): Streamable HTTP, o caminho de produção. -* Um `StdioServerParameters`: o comando a iniciar como **subprocesso**, com o qual se conversa pelo stdin e stdout dele. +* Uma string de URL (`Client("http://localhost:8000/mcp")`): Streamable HTTP, o transporte atrás do qual você faz o deploy. +* Um `StdioServerParameters`: o comando a iniciar como **subprocesso** local, com o qual se conversa pelo stdin e stdout dele. * Um **transporte**: qualquer coisa com que você possa fazer `async with ... as (read, write)`, como `streamable_http_client(url, http_client=...)` em volta do seu próprio cliente HTTP. +* Uma instância de `MCPServer` (ou do `Server` de baixo nível): conectada **no mesmo processo**, sem subprocesso e sem porta. Essa é para testes, e **[Testes](../get-started/testing.md)** se apoia nela. Todo o resto desta página é idêntico entre os quatro. Cabeçalhos, subprocessos, timeouts e o protocolo `Transport` têm sua própria página: **[Transportes do cliente](transports.md)**. @@ -49,11 +59,11 @@ Você nunca escolheu uma versão do protocolo. Por padrão, o `Client` sonda o s ## Listando ferramentas {#listing-tools} -```python title="client.py" hl_lines="15-20" +```python title="client.py" hl_lines="8-13" --8<-- "docs_src/client/tutorial002.py" ``` -`list_tools()` retorna um `ListToolsResult`; as ferramentas estão em `.tools`. Cada uma é a definição completa que um host entregaria a um modelo: +`list_tools()` retorna um `ListToolsResult`; as ferramentas estão em `.tools`. Cada uma é a definição completa que um host entregaria a um modelo. Eis a primeira: ```python tool.name # 'search_books' @@ -77,6 +87,8 @@ e `tool.input_schema` é o JSON Schema que o servidor derivou das anotações de Esse schema é tudo o que uma UI precisa para renderizar um formulário de argumentos, e tudo o que um modelo precisa para produzir argumentos válidos. +A segunda ferramenta, `lookup_book`, foi registrada sem um `title=`, então o `tool.title` dela é `None`. + !!! tip `title` é opcional, então uma UI que mostra ferramentas a um humano tem que escolher: o `title` se houver um, o `name` se não. `from mcp.shared.metadata_utils import get_display_name` faz exatamente isso, @@ -86,7 +98,7 @@ Esse schema é tudo o que uma UI precisa para renderizar um formulário de argum `call_tool(name, arguments)` executa a ferramenta e devolve um `CallToolResult`. -```python title="client.py" hl_lines="27-34" +```python title="client.py" hl_lines="9-16" --8<-- "docs_src/client/tutorial003.py" ``` @@ -142,7 +154,7 @@ Uma ferramenta que lança uma exceção **não** lança no seu cliente. Ela volt Os verbos de recurso vêm em pares: duas formas de listar, uma forma de ler. -```python title="client.py" hl_lines="22-31" +```python title="client.py" hl_lines="9-18" --8<-- "docs_src/client/tutorial004.py" ``` @@ -156,7 +168,7 @@ Um cliente também pode ser avisado quando um recurso muda. Em conexões da era ## Prompts {#prompts} -```python title="client.py" hl_lines="15-20" +```python title="client.py" hl_lines="8-13" --8<-- "docs_src/client/tutorial005.py" ``` @@ -181,7 +193,7 @@ Um host entrega essas mensagens direto ao modelo. A funcionalidade inteira é es Um servidor com um handler de completion pode autocompletar argumentos de prompts e de templates de recurso enquanto o usuário digita. -```python title="client.py" hl_lines="27-31" +```python title="client.py" hl_lines="9-13" --8<-- "docs_src/client/tutorial006.py" ``` @@ -194,21 +206,21 @@ A resposta está em `result.completion.values`. Digite `"p"` e o servidor volta Todo método `list_*` aceita um argumento nomeado `cursor=` e todo resultado carrega um `next_cursor`. Quando `next_cursor` é `None`, você tem tudo. -```python title="client.py" hl_lines="22-30" +```python title="client.py" hl_lines="7-15" --8<-- "docs_src/client/tutorial007.py" ``` -Esse loop está correto contra qualquer servidor. O `MCPServer` retorna tudo em uma página só, então `next_cursor` é `None` e o loop roda uma vez, e é por isso que a maioria do código nunca o escreve. Servidores que paginam de verdade, e as regras que os cursores obedecem, estão em **[Paginação](../advanced/pagination.md)**. +`list_all_tools` está correta contra qualquer servidor. O `MCPServer` retorna tudo em uma página só, então `next_cursor` é `None` e o loop roda uma vez, e é por isso que a maioria do código nunca o escreve. Servidores que paginam de verdade, e as regras que os cursores obedecem, estão em **[Paginação](../advanced/pagination.md)**. ## Em testes {#in-tests} -`Client(mcp)`, sem processo e sem porta, já é um harness de teste para o seu servidor. +Todo `client.py` desta página alcançou o `server.py` via HTTP. Em um teste você pula a rede e entrega ao `Client` o próprio objeto servidor: `from server import mcp`, depois `Client(mcp)`. Sem processo, sem porta, e todo método acima funciona igual. -Existe uma flag do construtor feita para isso: `Client(mcp, raise_exceptions=True)`. Ela só tem efeito em conexões em memória, e **[Testes](../get-started/testing.md)** é a página que a explica e constrói todo o padrão em torno dela. +Existe uma flag do construtor feita para isso: `Client(mcp, raise_exceptions=True)`. Ela só tem efeito em conexões no mesmo processo, e **[Testes](../get-started/testing.md)** é a página que a explica e constrói todo o padrão em torno dela. ## Recapitulando {#recap} -* `Client(x)` conecta em memória a um objeto servidor, via Streamable HTTP a uma string de URL, e por qualquer outra coisa via um transporte. +* `Client(x)` conecta via Streamable HTTP a uma string de URL, inicia um subprocesso para um `StdioServerParameters`, entra direto em um transporte e, em testes, recebe o próprio objeto servidor. * `async with` é o ciclo de vida inteiro. Dentro dele, `server_capabilities` e `protocol_version` já estão preenchidos; `server_info` e `instructions` também, quando o servidor os fornece. * `list_tools()` dá a você o `name`, `title`, `description` e `input_schema` de cada ferramenta. * `call_tool()` retorna `content` para o modelo, `structured_content` para o seu código e `is_error`. Uma ferramenta que lança exceção é um resultado, não uma exceção. diff --git a/i18n/pt/pages/client/oauth-clients.md b/i18n/pt/pages/client/oauth-clients.md index 51d1824f..7464435f 100644 --- a/i18n/pt/pages/client/oauth-clients.md +++ b/i18n/pt/pages/client/oauth-clients.md @@ -1,6 +1,6 @@ --- translation: - sections: [c6899d3892bd9fa0, 79372cff3cc48a88, 63878d29e87c3e73, 13175843d3588af4, e7e2b9fd516f77de, 758f06399b513c1f, a05d7278487d610b] + sections: [c6899d3892bd9fa0, 79372cff3cc48a88, c2dae1ebe2ebd543, 13175843d3588af4, df06056fb16b3846, 758f06399b513c1f, a05d7278487d610b] tool: 1 --- # Clientes OAuth {#oauth-clients} @@ -81,18 +81,20 @@ Veja `main()`. O provider vai no **cliente httpx2**, o cliente httpx2 vai em `st Na primeira vez que `Client` envia uma requisição, o servidor responde `401`. O provider assume: -1. **Descoberta.** Ele lê o cabeçalho `WWW-Authenticate`, busca os Protected Resource Metadata do servidor em `/.well-known/oauth-protected-resource`, descobre qual servidor de autorização protege este recurso e busca os metadados *desse* servidor. +1. **Descoberta.** Ele lê o cabeçalho `WWW-Authenticate`, busca os Protected Resource Metadata do servidor em `/.well-known/oauth-protected-resource`, descobre qual servidor de autorização protege este recurso e busca os metadados *desse* servidor. (A um servidor mais antigo, que não publica metadados de recurso, o provider pede os metadados do servidor de autorização na própria origem dele.) De um jeito ou de outro, os metadados precisam nomear, como `issuer`, o servidor para o qual foram buscados; qualquer outra coisa é recusada. 2. **Registro.** Nada no armazenamento? Ele registra você dinamicamente com o seu `OAuthClientMetadata` e armazena o resultado. 3. **Autorização.** Ele gera o par PKCE e um `state`, monta a URL de autorização, faz await no seu `redirect_handler` e depois faz await no seu `callback_handler` para obter o code. 4. **Troca.** Ele troca o code por um `OAuthToken`, armazena e reenvia a sua requisição original com `Authorization: Bearer ...`. Depois disso ele fica quieto. Os tokens saem do armazenamento, um token de acesso expirado é renovado com o refresh token, e só quando nada disso funciona ele executa o fluxo de novo. +Uma regra de transporte vale para todas essas requisições: assim como a requisição MCP dentro da qual elas acontecem, elas só seguem um redirecionamento quando ele fica na mesma origem e mantém o método (um 307/308 de barra final, digamos), e tratam qualquer outro redirecionamento como se aquela URL não respondesse. + Você não escreveu nada disso. Restam dois argumentos nomeados (`client_metadata_url` e `validate_resource_url`), e este arquivo não precisa de nenhum dos dois. `client_metadata_url` é o que vale a pena conhecer; ele ganha uma seção própria abaixo. ### Experimente {#try-it} -A maioria dos exemplos nesta documentação você consegue conferir com um `Client(server)` em memória. Este não: o ponto central do fluxo é um `401` HTTP, e não há HTTP entre um cliente em memória e o seu servidor. +O `Client(server)` em memória que os seus testes usam não ajuda aqui: o ponto central do fluxo é um `401` HTTP, e não há HTTP entre um cliente em memória e o seu servidor. O repositório traz a versão ao vivo. `examples/servers/simple-auth/` executa um servidor de autorização independente e um servidor MCP protegido; `examples/clients/simple-auth-client/` é o cliente desta página crescido até virar uma pequena CLI. O README dele tem os dois comandos: inicie os servidores, execute o cliente contra eles e veja as quatro etapas passarem. @@ -110,13 +112,14 @@ Um job noturno, uma etapa de CI, outro serviço. Não há navegador nem ninguém `ClientCredentialsOAuthProvider` é o mesmo `httpx2.Auth`, sem o humano: -```python title="client.py" hl_lines="4 27-33" +```python title="client.py" hl_lines="4 27-34" --8<-- "docs_src/oauth_clients/tutorial002.py" ``` O que mudou: * Sem `OAuthClientMetadata`, sem handlers. Você passa `client_id` e `client_secret`; o provider monta um registro `client_credentials` mínimo em torno deles e pula o registro dinâmico por completo. +* `issuer` nomeia o servidor de autorização que emitiu essas credenciais; use o valor `issuer` que o documento `/.well-known/oauth-authorization-server` dele retorna. A descoberta ainda acontece como acima, mas as requisições de token só são montadas a partir dos metadados *desse* issuer; se o servidor MCP apontar para qualquer outro lugar, o fluxo para com um `OAuthFlowError`. Deixá-lo de fora está obsoleto e ele passa a ser obrigatório na 3.0 (veja **[Funcionalidades obsoletas](../deprecated.md#deprecated-sdk-helpers)**); até lá, o provider emite um aviso e usa o servidor de autorização que a descoberta encontrar. * `scope` é uma string separada por espaços, o formato OAuth usado na comunicação. * Tudo a partir daí é idêntico: o mesmo `TokenStorage`, o mesmo `httpx2.AsyncClient(auth=...)`, o mesmo `streamable_http_client`. @@ -129,7 +132,7 @@ Por padrão, o secret viaja como HTTP Basic auth na requisição de token (`clie Mais um provider mora em `mcp.client.auth.extensions.client_credentials`: **`PrivateKeyJWTOAuthProvider`**, para clientes que se autenticam com um JWT em vez de um segredo compartilhado (`private_key_jwt`, a variante de par de chaves e workload identity). Ele segue - o mesmo padrão: construa um, coloque em `auth=`. O mesmo módulo traz + o mesmo padrão: construa um (ele aceita o mesmo `issuer` opcional), coloque em `auth=`. O mesmo módulo traz `SignedJWTParameters` e `static_assertion_provider`, dois helpers que montam a assertion dele. Há mais uma situação sem humano: o cliente pertence a uma empresa cujo provedor de identidade, e não o usuário, decide quais servidores MCP ele pode alcançar. Esse é um grant diferente, com seu próprio modelo de confiança e sua própria página, **[Asserção de identidade](identity-assertion.md)**. diff --git a/i18n/pt/pages/client/session-groups.md b/i18n/pt/pages/client/session-groups.md index 7f42531d..8b2324b2 100644 --- a/i18n/pt/pages/client/session-groups.md +++ b/i18n/pt/pages/client/session-groups.md @@ -1,6 +1,6 @@ --- translation: - sections: [09c857a25a9dc37a, 43bc6a76a243a50e, 0a716022a88768df, 4b7f78042bfcfff7, c112662e61b03315, 58974ba1f489a8b4, d18adbdbb835ea73] + sections: [09c857a25a9dc37a, 43bc6a76a243a50e, 0a716022a88768df, 4b7f78042bfcfff7, c112662e61b03315, 58974ba1f489a8b4, ed4d17e894864056] tool: 1 --- # Grupos de sessões {#session-groups} @@ -78,7 +78,7 @@ Se você já tem em mãos uma `ClientSession` conectada (`Client.session` é uma ## Recapitulando {#recap} * `ClientSessionGroup` mantém várias conexões de servidor e reúne as ferramentas, recursos e prompts delas em um `dict` para cada tipo. -* `connect_to_server(params)` por servidor. Ele recebe parâmetros de transporte, nunca o objeto de servidor ou a URL que um `Client` recebe. +* `connect_to_server(params)` por servidor. Ele recebe parâmetros de transporte, nunca a URL ou o `Transport` que um `Client` recebe. * `group.call_tool(name, arguments)` roteia para o servidor dono por você. * Os nomes precisam ser únicos no grupo inteiro; dois servidores com uma ferramenta `search` não conseguem coexistir por conta própria. * `component_name_hook=` reescreve cada nome registrado. A chave do dict muda, o nome na rede não. diff --git a/i18n/pt/pages/client/transports.md b/i18n/pt/pages/client/transports.md index 31504bac..7d8c4801 100644 --- a/i18n/pt/pages/client/transports.md +++ b/i18n/pt/pages/client/transports.md @@ -1,6 +1,6 @@ --- translation: - sections: [9cac816674181eb0, 0700f337babcd4dd, 2bde0dd58cdf00f5, 40b4916d82eaf1d4, 3d0832f39b0d7059, dfa4446556badef0, 5bd93be2ab2ecb9c] + sections: [9cac816674181eb0, 7c157764133fea1f, 40b4916d82eaf1d4, 10d151f2cc75317f, 3d0832f39b0d7059, 92742ba36533633d, 0aeca6145e7bd302] tool: 1 --- # Transportes do cliente {#client-transports} @@ -11,30 +11,15 @@ Você nunca configura um transporte separadamente. `Client` recebe um único arg O lado do *servidor* de cada um (o que `mcp.run()` faz e o que você coloca no deploy) está em **[Executando seu servidor](../run/index.md)**. -## Em memória {#in-memory} - -Passe o próprio objeto do servidor: - -```python title="client.py" hl_lines="14" ---8<-- "docs_src/client_transports/tutorial001.py" -``` - -Sem subprocesso, sem porta, sem bytes trafegando na rede. O cliente e o servidor são dois objetos no mesmo processo, e a chamada ainda passa pela camada real do protocolo: `search_books` é listada, validada e invocada exatamente como seria sobre HTTP. - -Isso faz dele duas coisas ao mesmo tempo: - -* **Uma estrutura de testes.** Todo exemplo desta documentação é exercitado dessa forma, e a página **[Testes](../get-started/testing.md)** constrói o padrão inteiro em torno disso. -* **Uma API de embutimento.** Uma aplicação que constrói o servidor não precisa de um salto pela rede para chamar as ferramentas dele. - ## Streamable HTTP {#streamable-http} -Passe uma string de URL e você tem **Streamable HTTP**, o transporte atrás do qual você faz o deploy: +Passe uma string de URL e você tem **Streamable HTTP**, o transporte atrás do qual você faz o deploy e o primeiro a que você deve recorrer: ```python title="client.py" hl_lines="5" --8<-- "docs_src/client_transports/tutorial002.py" ``` -Esse é o cliente de produção inteiro. `Client` envolve a URL em `streamable_http_client(...)` para você, sobre um `httpx2.AsyncClient` configurado do jeito que o MCP precisa: `follow_redirects=True`, um timeout de 30 segundos para connect/write/pool e um timeout de leitura de 300 segundos, porque o servidor pode manter um stream de resposta aberto. +Esse é o cliente de produção inteiro. `Client` envolve a URL em `streamable_http_client(...)` para você, sobre um `httpx2.AsyncClient` configurado do jeito que o MCP precisa: um timeout de 30 segundos para connect/write/pool e um timeout de leitura de 300 segundos, porque o servidor pode manter um stream de resposta aberto. !!! check Um `Client` que você construiu **não** está conectado. A construção só escolhe o transporte; @@ -50,7 +35,7 @@ Esse é o cliente de produção inteiro. `Client` envolve a URL em `streamable_h No momento em que você precisar de um header `Authorization`, um cookie, um proxy, mTLS ou um timeout diferente, construa o `httpx2.AsyncClient` você mesmo e entregue-o a `streamable_http_client`: -```python title="client.py" hl_lines="8-14" +```python title="client.py" hl_lines="8-13" --8<-- "docs_src/client_transports/tutorial003.py" ``` @@ -80,9 +65,30 @@ sem um repositório de CAs do sistema utilizável (alguns contêineres mínimos) !!! info `httpx2` mantém a API conhecida do `httpx`, então se você conhece `httpx` já sabe como fazer auth, proxies, event hooks, retentativas e limites de conexão aqui. O SDK não acrescenta nada por cima nem - tira nada. É também onde o OAuth se encaixa: + tira nada, exceto o [tratamento de redirecionamentos](#redirects). É também onde o OAuth se encaixa: `httpx2.AsyncClient(auth=OAuthClientProvider(...))`. Esse fluxo inteiro está em **[Clientes OAuth](oauth-clients.md)**. +### Redirecionamentos {#redirects} + +O transporte se conecta à URL que você forneceu, e somente a essa origem. + +* Um redirecionamento `307`/`308` que permanece no mesmo esquema, host e porta é seguido, e `http://` → `https://` no mesmo host também. Isso cobre o redirecionamento habitual de barra final `/mcp` → `/mcp/`. +* Um redirecionamento para qualquer outro lugar **não** é seguido. A chamada falha com: + + ```text + MCPError: Redirect to https://other.example.com/mcp not followed; use that URL as the endpoint if it is the intended server + ``` + + Se essa URL é o servidor que você queria, coloque-a na sua configuração. Se não é, o servidor ou um proxy na frente dele está mal configurado. + +Isso vale para qualquer `httpx2.AsyncClient` que você passe: a configuração `follow_redirects` dele não é consultada para requisições MCP, em nenhuma das direções. Os provedores OAuth do SDK aplicam a mesma regra às próprias requisições. + +!!! tip + `Redirect to http://… not followed: it would downgrade this HTTPS endpoint to plain HTTP` significa que o + servidor está atrás de um proxy com terminação TLS que ele desconhece e está emitindo redirecionamentos `http://`. + Isso se corrige no servidor (**[Deploy e escala](../run/deploy.md#behind-a-tls-terminating-proxy)**) + ou usando a URL exata `https://…/` que a mensagem sugere. + ## stdio {#stdio} Um servidor **stdio** é um subprocesso. O cliente o inicia, escreve JSON-RPC no stdin dele e lê JSON-RPC do stdout dele. É assim que um host de desktop executa um servidor na sua máquina: um host *é* este código mais uma interface, e **[Conecte a um host real](../get-started/real-host.md)** é a mesma relação vista do lado do host, como um arquivo de configuração. @@ -105,6 +111,18 @@ O stderr do processo filho vai para o seu. Para mandá-lo para outro lugar, cons Um servidor que precise de uma chave de API não vai encontrá-la ali. Passe-a explicitamente com `env=`; essas variáveis são mescladas por cima da allow-list. É isso que `BOOKSHOP_API_KEY` está fazendo acima. +## Em memória {#in-memory} + +Em um teste não há nada para colocar no deploy nem nada para iniciar. Passe o próprio objeto do servidor: + +```python hl_lines="14" +--8<-- "docs_src/client_transports/tutorial001.py" +``` + +Sem subprocesso, sem porta, sem bytes trafegando na rede. O cliente e o servidor são dois objetos no mesmo processo, e a chamada ainda passa pela camada real do protocolo: `search_books` é listada, validada e invocada exatamente como seria sobre HTTP. A página **[Testes](../get-started/testing.md)** constrói o padrão inteiro em torno disso. + +A mesma forma serve também como API de embutimento: uma aplicação que constrói o servidor por conta própria pode chamar as ferramentas dele sem um salto pela rede. + ## SSE {#sse} `sse_client(url)`, de `mcp.client.sse`, é o transporte HTTP que o Streamable HTTP substituiu. Envolva-o da mesma forma, `Client(sse_client("http://localhost:8000/sse"))`, para conversar com um servidor que ainda o fala, e não construa nada novo em cima dele. @@ -113,15 +131,16 @@ O stderr do processo filho vai para o seu. Para mandá-lo para outro lugar, cons Para o `Client`, tudo acima é a mesma coisa. -Um **transporte** é qualquer gerenciador de contexto assíncrono que produz um par `(read, write)` de streams de mensagens: formalmente, o protocolo `Transport` em `mcp.client`. `Client` resolve seu argumento pelo tipo: um objeto de servidor conecta no próprio processo, uma `str` vira `streamable_http_client(url)`, um `StdioServerParameters` vira `stdio_client(params)` e qualquer outra coisa é aberta diretamente como transporte. É por causa dessa última regra que `stdio_client(...)`, `streamable_http_client(...)` e `sse_client(...)` se encaixam todos no mesmo lugar, e que você pode escrever o seu próprio. +Um **transporte** é qualquer gerenciador de contexto assíncrono que produz um par `(read, write)` de streams de mensagens: formalmente, o protocolo `Transport` em `mcp.client`. `Client` resolve seu argumento pelo tipo: uma `str` vira `streamable_http_client(url)`, um `StdioServerParameters` vira `stdio_client(params)`, um objeto de servidor conecta no próprio processo e qualquer outra coisa é aberta diretamente como transporte. É por causa dessa última regra que `stdio_client(...)`, `streamable_http_client(...)` e `sse_client(...)` se encaixam todos no mesmo lugar, e que você pode escrever o seu próprio. ## Recapitulando {#recap} -* `Client(mcp)` (o objeto do servidor) conecta em memória. Use para testes e para embutir. * `Client("http://.../mcp")` (uma URL) conecta por Streamable HTTP, o transporte de produção. * Headers, auth, proxies e timeouts pertencem a um `httpx2.AsyncClient` que você passa a `streamable_http_client(url, http_client=...)`. Não existe o argumento `headers=`. +* Redirecionamentos só são seguidos dentro da própria origem da URL (um `307`/`308` de barra final), mais `http`→`https` no mesmo host. Qualquer outra coisa falha com `Redirect to … not followed`; configure a URL final. * stdio é `Client(StdioServerParameters(...))`. Envolva-o em `stdio_client(...)` você mesmo apenas para redirecionar o stderr do processo filho. * O subprocesso recebe um ambiente em allow-list, não o seu; `env=` acrescenta a ele. +* `Client(mcp)` (o objeto do servidor) conecta em memória. Use em testes, ou para embutir um servidor na aplicação que o construiu. * Um transporte é qualquer coisa com que você possa fazer `async with x as (read, write)`. `Client` entrega direto a esse protocolo tudo que não for um objeto de servidor, uma URL ou um `StdioServerParameters`. * Construir um `Client` escolhe o transporte. `async with` o abre. diff --git a/i18n/pt/pages/deprecated.md b/i18n/pt/pages/deprecated.md index 564d9ae2..a4d8f0e1 100644 --- a/i18n/pt/pages/deprecated.md +++ b/i18n/pt/pages/deprecated.md @@ -1,11 +1,11 @@ --- translation: - sections: [490237e61c3a7a44, 01262a123ad9501d, 429db5b574a2ac08, e2d0d273fbd2d74b, 64ab0331e868f3d4, 6c8878ce2d1f6d56, 4068f23e371bf0b3, eaef75b8725bc931] + sections: [4c1dea378b2b1bf7, 01262a123ad9501d, 429db5b574a2ac08, e2d0d273fbd2d74b, 64ab0331e868f3d4, 6c8878ce2d1f6d56, c3d1099701156881, e185a1b8e53669f6] tool: 1 --- # Funcionalidades descontinuadas {#deprecated-features} -A especificação 2026-07-28 aposenta cinco coisas. O SDK ainda implementa cada uma delas, e cada uma agora carrega um **aviso de descontinuação**. Um helper do SDK está descontinuado por conta própria e aparece listado [no final](#deprecated-sdk-helpers). +A especificação 2026-07-28 aposenta cinco coisas. O SDK ainda implementa cada uma delas, e cada uma agora carrega um **aviso de descontinuação**. Algumas descontinuações no nível do SDK valem por conta própria e aparecem listadas [no final](#deprecated-sdk-helpers). A tabela abaixo nomeia cada funcionalidade descontinuada, o motivo de ela estar saindo e o substituto sobre o qual construir. @@ -137,11 +137,13 @@ A API inteira é essa. Não há uma chave por método, e você não quer uma: o ## Helpers descontinuados do SDK {#deprecated-sdk-helpers} -Estas não são mudanças de especificação, apenas detalhes internos do SDK com um substituto melhor. Avisam com o mesmo `MCPDeprecationWarning` e serão removidos na 3.0. +Estas não são mudanças de especificação, apenas usos do SDK com um substituto melhor. Avisam com o mesmo `MCPDeprecationWarning`, e a 3.0 remove a forma antiga. | Descontinuado | O que fazer no lugar | |---|---| | `FuncMetadata.call_fn_with_arg_validation()` | `FuncMetadata.validate_arguments()` e depois `FuncMetadata.call_fn()`. Só código que conduz `FuncMetadata` diretamente (uma subclasse personalizada de `Tool`, digamos) chegou a chamá-lo. | +| `AuthSettings(resource_server_url=...)` sem `validate_token_resource=` | Defina-o: `True` faz o servidor recusar bearer tokens que o seu verificador não informa como emitidos para `resource_server_url`; `False` diz que o seu verificador confere a audiência do token por conta própria (veja **[Autorização](run/authorization.md#a-token-verifier)**). Sem definir, ele se comporta como `False`; a 3.0 torna `True` o padrão sempre que `resource_server_url` estiver definido. | +| `ClientCredentialsOAuthProvider(...)` ou `PrivateKeyJWTOAuthProvider(...)` sem `issuer=` | Passe `issuer=` nomeando o servidor de autorização que emitiu as credenciais (veja **[Escrevendo clientes OAuth](client/oauth-clients.md#machine-to-machine)**). Sem ele, é o servidor MCP que decide qual servidor de autorização as recebe; a 3.0 torna o parâmetro obrigatório. | ## Recapitulando {#recap} @@ -150,7 +152,7 @@ Estas não são mudanças de especificação, apenas detalhes internos do SDK co * Descontinuado é consultivo: sem mudanças no protocolo de transmissão, tudo continua funcionando em sessões pré-2026, e você recebe um `MCPDeprecationWarning` visível (um `UserWarning`, então está ligado por padrão). * Amostragem e roots precisam, além disso, de um canal de retorno que uma sessão 2026-07-28 não tem. Em uma conexão moderna elas avisam e depois lançam uma exceção. * `warnings.filterwarnings("ignore", category=MCPDeprecationWarning)` silencia a categoria inteira; `"error::mcp.MCPDeprecationWarning"` no pytest a transforma em falha de teste. -* Um helper do SDK, `FuncMetadata.call_fn_with_arg_validation()`, está descontinuado separadamente para remoção na 3.0. +* As [descontinuações no nível do SDK](#deprecated-sdk-helpers) seguem a mesma regra: avisam agora, e a 3.0 remove a forma antiga. * Código novo não deve ser construído sobre nenhuma delas. Todas as outras páginas desta documentação ensinam a API atual. diff --git a/i18n/pt/pages/get-started/first-steps.md b/i18n/pt/pages/get-started/first-steps.md index 32b8bb46..baeb501a 100644 --- a/i18n/pt/pages/get-started/first-steps.md +++ b/i18n/pt/pages/get-started/first-steps.md @@ -1,6 +1,6 @@ --- translation: - sections: [0d6c05bcbf836bf3, 59a7b14eeefc68c1, 7114d8d6daba203f, e8bbb56a98ba7bc9, 5138010f6159901c, f78da7c7c363d4c6, 220a939cab348686] + sections: [0d6c05bcbf836bf3, 9a78b5f6b44b18ab, 7114d8d6daba203f, e8bbb56a98ba7bc9, bfd2fd1153e71dac, 1615a994ef071fdd, 65c599fae991f245] tool: 1 --- # Primeiros passos {#first-steps} @@ -17,7 +17,7 @@ Três palavras que você vai ver em todas as páginas daqui em diante: * Um **cliente** vive dentro do host e fala MCP. O host executa um cliente para cada servidor ao qual está conectado. * Um **servidor** é o que você constrói com este SDK. Ele expõe coisas aos clientes. Nunca fala diretamente com o modelo. -Você escreve o servidor. Os hosts são produto de terceiros. O SDK também traz um `Client`. Você vai usá-lo para testar seus servidores, e ele aparece mais adiante nesta página. +Você escreve o servidor. Os hosts são produto de terceiros. O SDK também traz um `Client`, a mesma classe que um host usaria para alcançar um servidor por URL ou iniciá-lo como subprocesso. Ele aparece mais adiante nesta página, e é também com ele que você vai testar seus servidores. ## As três primitivas {#the-three-primitives} @@ -83,22 +83,20 @@ Você viu três abas no Inspector. Como ele sabia que eram três? Quando um cliente se conecta, o servidor declara suas **capacidades**: quais famílias de requisições ele vai responder. O cliente usa essa declaração para decidir o que faz sentido pedir. Você nunca escreveu isso; o `MCPServer` declara por você. -Veja você mesmo. O `Client` do SDK aceita o objeto do servidor diretamente e se conecta a ele **em memória** (sem subprocesso, sem porta): +Veja você mesmo. Deixe o `server.py` rodando via HTTP em um terminal: -```python -import asyncio +```console +uv run mcp run server.py --transport streamable-http +``` -from mcp import Client +e aponte um cliente para ele a partir de outro: -from server import mcp +```python title="client.py" hl_lines="7-8" +--8<-- "docs_src/first_steps/tutorial001_client.py" +``` - -async def main() -> None: - async with Client(mcp) as client: - print(client.server_capabilities.model_dump(exclude_none=True)) - - -asyncio.run(main()) +```console +python client.py ``` ```text @@ -118,8 +116,9 @@ O `MCPServer` serve as três primitivas, então as três são sempre declaradas. Repare no que não aparece ali. `completions` (autocompletar de argumentos para templates de recurso e prompts) precisa de um handler escrito por você; este servidor não tem nenhum, então a capacidade fica de fora e um cliente bem-comportado nem pede. Essa é a regra para tudo que é opcional: registre a coisa e a capacidade aparece; **[Completions](../servers/completions.md)** comprova isso. !!! info - `Client(mcp)` é o mesmo cliente em memória com que todos os exemplos desta documentação são - testados, e é assim que você vai testar os seus. Ele ganha uma página inteira: **[Testes](testing.md)**. + Esse `client.py` é um cliente MCP completo, e **[O cliente](../client/index.md)** é a página dele. + Em um teste, você pula o terminal e a porta e entrega ao `Client` o próprio objeto do servidor, + `Client(mcp)`. Isso também ganha uma página inteira: **[Testes](testing.md)**. ## O que você não escreveu {#what-you-did-not-write} @@ -128,7 +127,7 @@ Olhe de novo esta página. Você escreveu três funções Python pequenas. Você * Um JSON Schema. `a: int, b: int` *é* o schema de `add`. * Um handler de requisição. `tools/list`, `resources/read`, `prompts/get`: o SDK atende todos por você. * Uma declaração de capacidades. O `MCPServer` fez isso por você. -* Uma linha de protocolo. A negociação de versão, o enquadramento JSON-RPC, a troca de capacidades: tudo isso aconteceu dentro de `mcp dev` e `Client(mcp)`, e você nunca viu. +* Uma linha de protocolo. A negociação de versão, o enquadramento JSON-RPC, a troca de capacidades: tudo isso aconteceu dentro de `mcp dev` e `client.py`, e você nunca viu. Essa proporção é a razão de ser do SDK. @@ -139,6 +138,6 @@ Essa proporção é a razão de ser do SDK. * Um decorador por primitiva: `@mcp.tool()`, `@mcp.resource(uri)`, `@mcp.prompt()`. Nome, descrição e schema vêm da função. * Uma URI com um `{param}` cria um **template** de recurso, listado separadamente dos recursos concretos. * As **capacidades** do servidor já vêm declaradas para você, e um cliente só pede o que o servidor declara. -* `Client(mcp)` se conecta ao objeto do servidor em memória: seu ambiente de testes desde o primeiro dia. +* `Client("http://localhost:8000/mcp")` conversa com seu servidor em execução. Entregue a ele o objeto do servidor no lugar, `Client(mcp)`, e ele vira seu ambiente de testes desde o primeiro dia. A seguir vem **[Conecte a um host real](real-host.md)**: este servidor dentro do Claude Desktop ou de uma IDE, de verdade. Depois, **[Testes](testing.md)**: uma página, um cliente em memória, e você nunca mais fica adivinhando se funciona. Depois disso, cada primitiva ganha sua própria página, começando pela que o modelo comanda: **[Ferramentas](../servers/tools.md)**. diff --git a/i18n/pt/pages/get-started/testing.md b/i18n/pt/pages/get-started/testing.md index 2eb6c062..d2b49686 100644 --- a/i18n/pt/pages/get-started/testing.md +++ b/i18n/pt/pages/get-started/testing.md @@ -1,13 +1,13 @@ --- translation: - sections: ['4926721070127497', c52a1de2b6b32f40, 8e792bf8c7489ec6, 627195f7159e24ef] + sections: [5d13c2f0ba42c0d2, c52a1de2b6b32f40, 8e792bf8c7489ec6, 38552ea228b0a04f] tool: 1 --- # Testes {#testing} -O SDK Python traz uma classe `Client` com um **transporte em memória**: passe a ela o objeto do seu servidor e ela se conecta diretamente a ele. +A classe `Client` do SDK, a mesma que se conecta a uma URL ou inicia um subprocesso, também se conecta **em memória**: passe a ela o objeto do seu servidor e ela conversa diretamente com ele. -Sem subprocesso. Sem porta. Sem transporte nenhum. É a mesma ideia do `TestClient` do FastAPI. +Sem subprocesso. Sem porta. Nada trafegando na rede. É a mesma ideia do `TestClient` do FastAPI. ## Uso básico {#basic-usage} @@ -97,7 +97,7 @@ em vez da sanitizada. Deixe-a ligada nos testes. Em código de produção, ela não significa nada. -## No mesmo processo por padrão {#in-process-by-default} +## Neutro quanto à era por padrão {#era-neutral-by-default} !!! note `Client(mcp)` se conecta no mesmo processo e é **neutro quanto à era** por padrão: ele sonda o servidor e diff --git a/i18n/pt/pages/handlers/multi-round-trip.md b/i18n/pt/pages/handlers/multi-round-trip.md index 77432a57..ff7b0448 100644 --- a/i18n/pt/pages/handlers/multi-round-trip.md +++ b/i18n/pt/pages/handlers/multi-round-trip.md @@ -1,6 +1,6 @@ --- translation: - sections: [74011e683045eea9, 9b64cc175c18b6a9, 4b41be4824030397, e3b1502da786ec33, 71e41161f143c6a9, 9ec2c1eeb8c36378, 8dd027377d46448b, f81491125dcbfe8b] + sections: [74011e683045eea9, 9b64cc175c18b6a9, 4b41be4824030397, e3b1502da786ec33, 71e41161f143c6a9, 9ec2c1eeb8c36378, b47667184ca5b516, f81491125dcbfe8b] tool: 1 --- # Requisições com múltiplas idas e voltas {#multi-round-trip-requests} @@ -164,7 +164,7 @@ O `Server` de baixo nível é o nível sem pilhas inclusas: diferente do `MCPSer ## Um resultado de 2026-07-28 {#a-2026-07-28-result} -`InputRequiredResult` só existe na versão de protocolo **2026-07-28**. O `Client(server)` em memória a negocia por você; pela rede, `mode="auto"` a descobre. Depois de conectar, `client.protocol_version` diz o que você obteve. +`InputRequiredResult` só existe na versão de protocolo **2026-07-28**. O `mode="auto"` padrão do `Client` a descobre em qualquer conexão. Depois de conectar, `client.protocol_version` diz o que você obteve. !!! warning Uma sessão pré-2026 não tem onde colocar um `InputRequiredResult`. Retorne um do seu handler em uma diff --git a/i18n/pt/pages/handlers/progress.md b/i18n/pt/pages/handlers/progress.md index caa2c094..f6b78f8c 100644 --- a/i18n/pt/pages/handlers/progress.md +++ b/i18n/pt/pages/handlers/progress.md @@ -1,6 +1,6 @@ --- translation: - sections: [5315262fe26b33e1, 9d8e98840f1b78f0, 0284b215e85366c4, 8534d8dbb4053a70, 2966fac6fe697007] + sections: [5315262fe26b33e1, 9d8e98840f1b78f0, 52d6009a07e770ea, 8534d8dbb4053a70, 2966fac6fe697007] tool: 1 --- # Progresso {#progress} @@ -29,19 +29,17 @@ Três argumentos, e você decide o que eles significam: O cliente opta por receber **por chamada**, passando `progress_callback=` para `call_tool`: -```python title="client.py" hl_lines="7 16" +```python title="client.py" hl_lines="5 14" import anyio from mcp import Client -from server import mcp - async def show(progress: float, total: float | None, message: str | None) -> None: print(f"{message} ({progress}/{total})") async def main() -> None: - async with Client(mcp) as client: + async with Client("http://localhost:8000/mcp") as client: result = await client.call_tool( "import_catalog", {"urls": ["https://example.com/a.json", "https://example.com/b.json"]}, @@ -56,28 +54,31 @@ anyio.run(main) O callback é uma função `async` que recebe exatamente o que o servidor informou: `progress`, `total`, `message`. !!! info - `Client(mcp)` conecta direto ao objeto do servidor, em memória, o mesmo cliente sobre o qual a página **[Testes](../get-started/testing.md)** - é construída. `progress_callback` é o mesmo parâmetro seja qual for o transporte que o `Client` - usa; o *timing* que você está prestes a ver é o da conexão em memória. Ela executa seu callback - inline, então todo informe chega antes de `call_tool` retornar. Em um transporte real, as - notificações disputam corrida com o resultado, e um callback lento ainda pode estar executando depois que `call_tool` - retornou. + `progress_callback` é o mesmo parâmetro seja lá o que você tenha passado ao `Client`: uma URL como aqui, um + `StdioServerParameters`, ou o objeto do servidor em um teste. Mas fique atento ao timing em um transporte + real. Cada notificação é entregue por conta própria, ao lado da resposta, então um callback lento + ainda pode estar executando depois que `call_tool` retornou. Só a conexão de teste em processo + executa o callback inline e garante que todo informe chegue antes. ### Experimente {#try-it} -Coloque `client.py` ao lado de `server.py` e execute: +Sirva `server.py` por HTTP e depois execute o cliente em um segundo terminal: + +```console +uv run mcp run server.py --transport streamable-http +``` ```console python client.py ``` ```text -Imported https://example.com/a.json (1/2) -Imported https://example.com/b.json (2/2) +Imported https://example.com/a.json (1.0/2.0) +Imported https://example.com/b.json (2.0/2.0) {'result': 'Imported 2 records.'} ``` -Cada `await ctx.report_progress(...)` no servidor virou uma chamada a `show` no cliente, em ordem, e as duas linhas foram impressas **antes** de `call_tool` retornar. O progresso não vem embutido no resultado; ele é transmitido enquanto a ferramenta ainda está trabalhando. +Cada `await ctx.report_progress(...)` no servidor virou uma chamada a `show` no cliente, em ordem. O progresso não vem embutido no resultado. Ele é transmitido enquanto a ferramenta ainda está trabalhando. !!! warning `progress_callback` pertence à **chamada**, não ao `Client`. Não há argumento de construtor diff --git a/i18n/pt/pages/protocol-versions.md b/i18n/pt/pages/protocol-versions.md index 6ce3ae2f..44cec718 100644 --- a/i18n/pt/pages/protocol-versions.md +++ b/i18n/pt/pages/protocol-versions.md @@ -1,6 +1,6 @@ --- translation: - sections: [478fd619e5f90ef8, aef094a00e44e248, bab8cbf3449fa7e9, df1809b15a58335b, 5f9d8c2336ed0239, f54974398e43ddef, b24443dd78584870] + sections: [d98e337bf28845e0, dd642acbba36f615, f547b3e76da19c94, 149513378588da5c, 4e149ed992046709, f54974398e43ddef, b24443dd78584870] tool: 1 --- # Versões do protocolo {#protocol-versions} @@ -11,9 +11,17 @@ Os servidores lançados antes de 2026-07-28 abrem toda conexão com o **handshak Você quase nunca precisa se preocupar com isso, porque o `Client` negocia por você. Esta página trata do único argumento do construtor que controla isso, `mode=`, e das três situações em que você o altera. +Cada trecho de código desta página é um `client.py` que conversa com o `server.py` da Bookshop de **[O cliente](client/index.md)**. Inicie esse servidor em um terminal: + +```console +uv run mcp run server.py --transport streamable-http +``` + +Depois execute cada trecho em um segundo terminal com `python client.py`. + ## `mode="auto"` {#modeauto} -```python title="client.py" hl_lines="14-15" +```python title="client.py" hl_lines="7-8" --8<-- "docs_src/protocol_versions/tutorial001.py" ``` @@ -31,13 +39,14 @@ De um jeito ou de outro você sai conectado, e `client.protocol_version` diz qua A funcionalidade inteira é essa. Um `Client`, qualquer era de servidor, sem ramificações no seu código. !!! info - O `MCPServer` responde a `server/discover` em todos os transportes — em memória, stdio, streamable - HTTP — então, contra o seu próprio servidor, `auto` sempre chega em `2026-07-28`. O fallback só - dispara contra um servidor real anterior a 2026, que é exatamente quando você quer que ele dispare. + O `MCPServer` responde a `server/discover` em todos os transportes — Streamable HTTP, stdio e a + conexão no mesmo processo que os seus testes usam —, então, contra o seu próprio servidor, `auto` + sempre chega em `2026-07-28`. O fallback só dispara contra um servidor real anterior a 2026, que é + exatamente quando você quer que ele dispare. ## `mode="legacy"` {#modelegacy} -```python title="client.py" hl_lines="14" +```python title="client.py" hl_lines="7" --8<-- "docs_src/protocol_versions/tutorial002.py" ``` @@ -61,7 +70,7 @@ Em 2026-07-28 ele não existe mais. O servidor *retorna* suas perguntas e você `mode` também aceita uma string de versão moderna do protocolo. Hoje esse conjunto é exatamente `["2026-07-28"]`. -```python title="client.py" hl_lines="14" +```python title="client.py" hl_lines="7" --8<-- "docs_src/protocol_versions/tutorial003.py" ``` @@ -94,7 +103,7 @@ A sondagem é barata, mas ainda é uma ida e volta que você paga a cada reconex Então guarde-a. Depois de uma conexão `auto`, `client.session.discover_result` contém o `DiscoverResult` exato que o servidor enviou: seu `supported_versions`, seu `capabilities`, seu `instructions` e a identidade que o servidor carimbou no `_meta` do resultado. Passe-o de volta como `prior_discover=` na próxima vez: -```python title="client.py" hl_lines="15 17" +```python title="client.py" hl_lines="8 10" --8<-- "docs_src/protocol_versions/tutorial004.py" ``` diff --git a/i18n/pt/pages/run/asgi.md b/i18n/pt/pages/run/asgi.md index 563ef3d9..940c732b 100644 --- a/i18n/pt/pages/run/asgi.md +++ b/i18n/pt/pages/run/asgi.md @@ -1,6 +1,6 @@ --- translation: - sections: [1062ef792791488a, 4be2b831547184a9, 374b049e770385f2, b72f6947089e6de0, b172c9db7831bb31, 70b9ece244ca1b0c, cba78e052898c3f6, f06bdb541cb0b469, fb82d526320b7cc3] + sections: [1062ef792791488a, 4be2b831547184a9, 374b049e770385f2, b72f6947089e6de0, b172c9db7831bb31, 10394c6f16601638, cba78e052898c3f6, f06bdb541cb0b469, 6dc898ccc5a903f9] tool: 1 --- # Adicione a um app existente {#add-to-an-existing-app} @@ -99,7 +99,7 @@ Aquele `/mcp` no final é o `streamable_http_path`. Defina-o como `"/"` e o pref --8<-- "docs_src/asgi/tutorial004.py" ``` -Agora os clientes se conectam a `/notes`, não a `/notes/mcp`. +Agora os clientes se conectam a `/notes/`, não a `/notes/mcp`. ## CORS para clientes no navegador {#cors-for-browser-clients} @@ -142,4 +142,4 @@ Um cliente que roda no navegador precisa de duas permissões suas: para **enviar * Clientes no navegador precisam de CORS: `allow_headers` para os headers de requisição `Mcp-*`, `expose_headers=["Mcp-Session-Id"]` para a resposta. * `@mcp.custom_route()` adiciona endpoints HTTP comuns, sem autenticação, ao lado de `/mcp`. -Com o servidor acessível em uma URL real, **[O cliente](../client/index.md)** se conecta a ele com essa URL em vez de um objeto servidor. +Com o servidor acessível em uma URL real, **[O cliente](../client/index.md)** se conecta a ele com essa URL. diff --git a/i18n/pt/pages/run/authorization.md b/i18n/pt/pages/run/authorization.md index 468206c7..1adb3576 100644 --- a/i18n/pt/pages/run/authorization.md +++ b/i18n/pt/pages/run/authorization.md @@ -1,6 +1,6 @@ --- translation: - sections: [d62c13457fc4a534, 80e73abaca6e0652, d1dc4c54cd00ec9c, 14ad3bc7904036bb, 5225f127bc1b9c77, fe1626fdd5aad1da, 4556cb7ea1a04a31] + sections: [d62c13457fc4a534, 80e73abaca6e0652, 128a492d18295f64, 14ad3bc7904036bb, 54a6697833fcc1d9, fe1626fdd5aad1da, 811d083c1da8bcf6] tool: 1 --- # Autorização {#authorization} @@ -23,12 +23,12 @@ O triângulo inteiro é esse. Tudo nesta página é o item do meio. O SDK não tem opinião sobre como é um token válido. Você diz a ele, implementando **`TokenVerifier`**: -```python title="server.py" hl_lines="12-14 19-24" +```python title="server.py" hl_lines="14-16 21-27" --8<-- "docs_src/authorization/tutorial001.py" ``` * `TokenVerifier` é um protocolo com um único método assíncrono. `verify_token` recebe o token bruto do header `Authorization` e retorna um **`AccessToken`** se ele for válido, `None` se não for. Não há mais nada a implementar. -* Este aqui procura o token em uma tabela. Um de verdade verifica a assinatura de um JWT ou chama o endpoint de introspecção de tokens do servidor de autorização. Esse código é seu; o SDK apenas o chama. +* Este aqui procura o token em uma tabela; cada entrada registra o recurso para o qual ele foi emitido. Um de verdade verifica a assinatura de um JWT ou chama o endpoint de introspecção de tokens do servidor de autorização, e informa para quem o token foi emitido (seu `aud`) em `AccessToken.resource`. Esse código é seu; o SDK apenas o chama. * `token_verifier=` e `auth=` sempre andam juntos. Passe um sem o outro e `MCPServer(...)` levanta um `ValueError` antes mesmo de atender uma requisição. `AuthSettings` é a face pública do seu resource server: @@ -36,6 +36,10 @@ O SDK não tem opinião sobre como é um token válido. Você diz a ele, impleme * `issuer_url`: o servidor de autorização que emite seus tokens. * `resource_server_url`: a URL pública deste endpoint MCP. Ela indica *a qual* recurso um token se destina, e é onde fica o documento de descoberta. * `required_scopes`: todo token deve conter todos eles. +* `validate_token_resource`: recusa qualquer token cujo `AccessToken.resource` não seja `resource_server_url`. Deixá-lo sem definir enquanto `resource_server_url` está definido emite um aviso (`MCPDeprecationWarning`) e se comporta como `False`; a versão 3.0 torna `True` o padrão para resource servers. + * Ative-o quando seu servidor de autorização vincula os tokens ao `resource` que o cliente pediu, o que clientes MCP sempre enviam. Mantenha `resource_server_url` como a URL exata à qual os clientes se conectam. + * Deixe-o desligado quando seu servidor de autorização usa seus próprios identificadores de audiência (um identificador de API do Auth0, um ID de aplicação do Entra) e, em vez disso, verifique `aud` no seu verificador, retornando `None` para um token que não é para este servidor. + * Se `aud` for uma lista, coloque em `resource` a entrada que é igual a `resource_server_url`. !!! tip `examples/servers/simple-auth/` no repositório do SDK tem um `IntrospectionTokenVerifier` que chama @@ -91,7 +95,7 @@ Esse documento é como um cliente que nunca ouviu falar do seu servidor encontra Dentro de qualquer handler, **`get_access_token()`** é o `AccessToken` que seu verificador retornou para a requisição atual: -```python title="server.py" hl_lines="4 32-35" +```python title="server.py" hl_lines="4 35-38" --8<-- "docs_src/authorization/tutorial002.py" ``` @@ -125,6 +129,6 @@ Um servidor de autorização também pode aceitar a asserção assinada de um pr * `token_verifier=` e `auth=AuthSettings(issuer_url=..., resource_server_url=..., required_scopes=[...])` sempre andam juntos. * O SDK publica o Protected Resource Metadata da [RFC 9728](https://datatracker.ietf.org/doc/html/rfc9728) em `/.well-known/oauth-protected-resource/...` e responde a requisições não autenticadas com um 401 cujo header `WWW-Authenticate` aponta para ele. A história da descoberta é toda essa. * `get_access_token()` em qualquer handler diz quem está chamando. -* Autorização é assunto do HTTP. O `stdio` e o cliente em memória nunca a veem. +* Autorização é assunto do HTTP. O `stdio` e o cliente de teste em memória nunca a veem. A metade do cliente (descobrir seu servidor de autorização e buscar o token para você) está em **[Clientes OAuth](../client/oauth-clients.md)**. E um cliente que *afirma* uma identidade em vez de pedir uma ao usuário está em **[Asserção de identidade](../client/identity-assertion.md)**. diff --git a/i18n/pt/pages/run/deploy.md b/i18n/pt/pages/run/deploy.md index 8561e0e1..c4d05448 100644 --- a/i18n/pt/pages/run/deploy.md +++ b/i18n/pt/pages/run/deploy.md @@ -1,6 +1,6 @@ --- translation: - sections: [28221886b198784f, f88ea1f1614f3a1d, ce926d686730b6d0, 3be24f8ad8bb5ab9, 3fad24032b2224ff, f25a7f860e579ecb, e758745df6fb7b0a] + sections: [28221886b198784f, f88ea1f1614f3a1d, 2e76f5cb9df15042, ce926d686730b6d0, 3be24f8ad8bb5ab9, 3fad24032b2224ff, f25a7f860e579ecb, 697b01d95080880d] tool: 1 --- # Deploy e escala {#deploy-scale} @@ -47,6 +47,22 @@ Depois do deploy atrás de um hostname de verdade, esse mesmo padrão rejeita ** que recusa toda conexão é uma allowlist de Host até que se prove o contrário. **[Solução de problemas](../troubleshooting.md)** também começa por aqui. +## Atrás de um proxy que termina TLS {#behind-a-tls-terminating-proxy} + +Se o TLS termina em um proxy (um ingress, um balanceador de carga, Caddy, nginx) e o uvicorn serve HTTP puro atrás dele, diga ao uvicorn para confiar nos headers `X-Forwarded-*` do proxy: + +```console +uvicorn server:app --proxy-headers --forwarded-allow-ips='' +``` + +Sem isso, o app acredita que está sendo servido por `http://`, e qualquer redirecionamento que ele emita (o habitual é `/mcp` → `/mcp/`) aponta para `http://…`. O cliente Python se recusa a seguir um endpoint HTTPS para HTTP puro e avisa: + +```text +MCPError: Redirect to http://mcp.example.com/mcp/ not followed: it would downgrade this HTTPS endpoint to plain HTTP. +``` + +O paliativo do lado do cliente é configurar a URL exata que o servidor serve (`https://mcp.example.com/mcp/`, com a barra) para que nenhum redirecionamento aconteça. A correção é a flag acima. `FORWARDED_ALLOW_IPS` é a grafia como variável de ambiente; `*` confia em todo salto, o que só está certo quando nada além do proxy consegue alcançar o uvicorn. + ## Workers, e quem precisa de afinidade {#workers-and-who-has-to-be-sticky} Quando o hostname responder, coloque mais de um worker atrás dele. Não há botão no SDK para isso; você escala um app Starlette do jeito que escala qualquer app ASGI, entregando o objeto a algo que saiba fazer fork: @@ -170,6 +186,7 @@ Um `MCPServer` é uma implementação de protocolo, não um servidor de aplicaç ## Recapitulando {#recap} * Por padrão, o app responde apenas a requisições endereçadas ao localhost. `transport_security=TransportSecuritySettings(allowed_hosts=[...], allowed_origins=[...])` é o portão para ir ao ar: até você passar isso, toda requisição atrás de um hostname de verdade é um `421` e o motivo só está no log do servidor. +* Atrás de um proxy que termina TLS, execute o uvicorn com `--proxy-headers --forwarded-allow-ips=...`, senão os redirecionamentos dele apontam para `http://` e o cliente os recusa. * No 2026-07-28 não há sessão e nada em que um balanceador de carga possa ter afinidade. `stateless_http=True` é um botão só para o legado porque uma requisição moderna é roteada e respondida antes de essa flag ser lida. * A chave padrão do `requestState` é `os.urandom(32)`, cunhada por processo. Uma nova tentativa de múltiplas idas e voltas que chega a um worker diferente falha com `-32602` *"Invalid or expired requestState"*. * A correção é `RequestStateSecurity(keys=[...])` **e** o mesmo nome de servidor em toda instância. O nome é a claim de audiência padrão do token. Mesmas chaves, mesmo nome. diff --git a/i18n/pt/pages/run/index.md b/i18n/pt/pages/run/index.md index 791f05e4..f190bd5c 100644 --- a/i18n/pt/pages/run/index.md +++ b/i18n/pt/pages/run/index.md @@ -1,6 +1,6 @@ --- translation: - sections: [fea8d769ff9edeba, ce8e2ad42f29ef71, 0d705efb19cf99c2, 2fd7cf825e6d2b2c, 9adc400e8c88e854, 318893ad8e2e9924, 6b63ab96b34476c0] + sections: [fea8d769ff9edeba, ce8e2ad42f29ef71, 0d705efb19cf99c2, 9fd2357154a5b7e7, 9adc400e8c88e854, 318893ad8e2e9924, 6b63ab96b34476c0] tool: 1 --- # Executando seu servidor {#running-your-server} @@ -75,6 +75,11 @@ Cada transporte tem seus próprios argumentos nomeados, todos em `run()`: * `max_request_body_size`: maior corpo de requisição aceito, em bytes. O padrão é 4 MiB; requisições maiores recebem HTTP 413 antes do parsing ou da criação da sessão. Aumente apenas quando mensagens MCP legítimas ultrapassarem esse tamanho. +* `session_idle_timeout`: segundos que uma sessão legada pode ficar sem nada em andamento antes de o + servidor fechá-la. Padrão 1800. `None` desativa. Veja + [Tempo de vida e limites de sessão](legacy-clients.md#session-lifetime-and-limits). +* `max_sessions`: quantas sessões legadas um processo mantém ao mesmo tempo. Padrão 10 000. `None` + remove o limite. Tratado na mesma seção. * `event_store`, `retry_interval`, `transport_security`: retomada e proteção contra DNS rebinding. Podem esperar até você fazer o deploy em algum lugar que não seja o localhost; **[Deploy e escala](deploy.md)** cobre `transport_security`. !!! warning diff --git a/i18n/pt/pages/run/legacy-clients.md b/i18n/pt/pages/run/legacy-clients.md index e6f0349d..23adca9c 100644 --- a/i18n/pt/pages/run/legacy-clients.md +++ b/i18n/pt/pages/run/legacy-clients.md @@ -1,6 +1,6 @@ --- translation: - sections: [3d1663c18edc824c, d4fd37009a13f03d, af9f398a5a8b679a, 470c2dd144294d69, 8e45827e6d24e8c8, 91dfd0ce98ebb03c] + sections: [3d1663c18edc824c, 90956965ae6a1ca1, af9f398a5a8b679a, 5ce83b1f9d88da62, 0d9b5d13fffc94e5, 8e45827e6d24e8c8, 91dfd0ce98ebb03c] tool: 1 --- # Atendendo clientes legados {#serving-legacy-clients} @@ -21,15 +21,25 @@ Então um cliente legado não é algo *para* o qual você constrói. É algo que ## Um handler, as duas eras {#one-handler-both-eras} -Aqui está uma ferramenta (tool) que precisa perguntar algo ao usuário, e clientes das duas eras chamando-a: +Aqui está uma ferramenta (tool) que precisa perguntar algo ao usuário: -```python title="server.py" hl_lines="24 37-38" +```python title="server.py" hl_lines="21" --8<-- "docs_src/legacy_clients/tutorial001.py" ``` `reserve` precisa de uma coisa que o modelo não forneceu: quantas cópias. `Annotated[..., Resolve(ask_quantity)]` é como uma ferramenta declara isso (**[Dependências](../handlers/dependencies.md)** tem essa história completa). Nada em `reserve` cita uma versão, verifica uma capacidade ou ramifica. -Os dois clientes ficam abertos **ao mesmo tempo**, no mesmo objeto `mcp`. `mode="legacy"` executa o handshake `initialize`: exatamente a conexão que um cliente pré-2026 abre. O outro usa o padrão e cai em `2026-07-28`. +Sirva-a via HTTP, e aqui estão clientes das duas eras chamando-a: + +```console +uv run mcp run server.py --transport streamable-http +``` + +```python title="client.py" hl_lines="14-15" +--8<-- "docs_src/legacy_clients/tutorial001_client.py" +``` + +Os dois clientes ficam abertos **ao mesmo tempo**, contra o mesmo servidor em execução. `mode="legacy"` executa o handshake `initialize`: exatamente a conexão que um cliente pré-2026 abre. O outro usa o padrão e cai em `2026-07-28`. Execute `python client.py` em um segundo terminal: ```text 2025-11-25 {'result': "Reserved 2 of 'Dune'."} @@ -61,6 +71,40 @@ Com um worker, isso é invisível. Com dois, é o problema inteiro: uma requisi perdidos para um cliente que se reconecta à *mesma* sessão), não um armazenamento de sessões. Ele nunca torna uma sessão alcançável a partir de outro processo. +## Tempo de vida e limites de sessão {#session-lifetime-and-limits} + +Uma sessão legada não vive para sempre, e um processo não mantém um número ilimitado +delas. Duas configurações controlam isso. Ambas são argumentos nomeados em `run()`, `streamable_http_app()` +e `Server.streamable_http_app()`. Conexões modernas (`2026-07-28`) e `stateless_http=True` +não têm sessões, então nenhuma das duas configurações se aplica a elas. + +| Configuração | Padrão | O que faz | O que o cliente vê | Para desligar | +|---|---|---|---|---| +| `session_idle_timeout` | `1800` (30 min) | Fecha uma sessão que não teve nada em andamento por esse tempo. | `404 Session not found`. Ele precisa fazer `initialize` de novo. | `None` | +| `max_sessions` | `10_000` | Recusa abrir uma sessão além dessa quantidade. As sessões existentes ficam intactas e nada é despejado. | `503 Too many open sessions` com o código JSON-RPC `-32603`. | `None` | + +O que conta como "em andamento": + +* Um stream `GET` aberto. Os clientes do SDK mantêm um aberto, então a sessão de um cliente conectado nunca + expira. +* Uma requisição que ainda está sendo respondida. Uma chamada de ferramenta que roda por mais tempo que o timeout não é + interrompida, e a contagem regressiva só começa quando ela termina. +* Nada mais. Entre requisições, o relógio corre. Qualquer requisição na sessão o reinicia, + `ping` inclusive. Depois que uma sessão expirou, nada a revive. + +Um cliente que encerra sua sessão com `DELETE` a libera imediatamente. O mesmo vale para um cliente cuja +requisição de abertura foi recusada. + +```python +mcp.run(transport="streamable-http", session_idle_timeout=None, max_sessions=50_000) +``` + +Os dois eventos aparecem no log do servidor. Uma expiração é `Session idle timeout` em `INFO`. Uma +abertura recusada é `Refusing to open a new session: sessions are already open` em `WARNING`. + +Os limites são por processo. Com quatro workers, o teto é quatro vezes `max_sessions`, e cada +worker expira suas próprias sessões. + ## A única chave: `stateless_http` {#the-one-knob-stateless_http} Se stickiness é um custo que você se recusa a pagar, existe exatamente uma coisa que você pode mudar. @@ -86,7 +130,7 @@ Duas coisas sobre ele importam mais do que o que ele faz. !!! check Faça a coisa errada. `reserve` é exatamente a ferramenta que acabou de atender os dois clientes. Faça o deploy dela com - `stateless_http=True`, conecte os mesmos dois clientes via HTTP e chame-a de cada um. + `stateless_http=True`, conecte os mesmos dois clientes e chame-a de cada um. O cliente moderno ainda recebe `Reserved 2 of 'Dune'.` A perna moderna não mudou. diff --git a/i18n/pt/pages/troubleshooting.md b/i18n/pt/pages/troubleshooting.md index ed7b4b7e..29e6e3c1 100644 --- a/i18n/pt/pages/troubleshooting.md +++ b/i18n/pt/pages/troubleshooting.md @@ -1,6 +1,6 @@ --- translation: - sections: [2efaecdef109a5c5, fcacd3e66b8635a4, 25323d737dcf0261, 8a6e351ec756904d, 137454d469c867f5, 6392596bd6df54f0, 41126fa9c4fe432f, 480b6d7897e30ab4, d83bb682e708dde0, ebbed3449c499db4, 323ef84f6b4bebde, 30fd31be74169d9a, 656943c6cb567218, c2dc3b1007d2e987, 7cf5386b997d04e9, 0b59feed8384456e, 0cba47bae78d04eb, e4355f4c7cf4fb2e] + sections: [3d58228e81b99543, 170514ce901c4139, 17d61fad0a50d62b, 8a6e351ec756904d, 137454d469c867f5, 6392596bd6df54f0, 41126fa9c4fe432f, 480b6d7897e30ab4, d83bb682e708dde0, ebbed3449c499db4, 525cdf1755e29d4c, 30fd31be74169d9a, d2e88333d4f7841f, c2dc3b1007d2e987, d6eabf60cc366341, f798e815252852c2, 0cba47bae78d04eb, 2c218ba829abf74e] tool: 1 --- # Solução de problemas {#troubleshooting} @@ -13,6 +13,12 @@ Várias entradas usam este mesmo servidor. Uma ferramenta (tool) e um recurso co --8<-- "docs_src/troubleshooting/tutorial001.py" ``` +Essas entradas o acessam em `http://localhost:8000/mcp`, então deixe-o rodando via HTTP: + +```console +uv run mcp run server.py --transport streamable-http +``` + Os erros que esta página cita são reais: a própria suíte de testes do SDK reproduz cada um deles. ## `ExceptionGroup: unhandled errors in a TaskGroup (1 sub-exception)` {#exceptiongroup-unhandled-errors-in-a-taskgroup-1-sub-exception} @@ -23,7 +29,7 @@ Isto não é um erro do MCP. É ruído do anyio, e o seu erro de verdade é a ** ```python async def main() -> None: - async with Client(mcp) as client: + async with Client("http://localhost:8000/mcp") as client: await client.read_resource("weather://Atlantis") ``` @@ -49,7 +55,7 @@ Duas coisas a fazer com isso: ```python async def main() -> None: - async with Client(mcp) as client: + async with Client("http://localhost:8000/mcp") as client: try: await client.read_resource("weather://Atlantis") except MCPError as e: @@ -67,7 +73,7 @@ async def main() -> None: ```python async def main() -> None: - client = Client(mcp) + client = Client("http://localhost:8000/mcp") tools = await client.list_tools() # RuntimeError ``` @@ -75,7 +81,7 @@ Entre nele. `__aenter__` é a conexão: ```python async def main() -> None: - async with Client(mcp) as client: + async with Client("http://localhost:8000/mcp") as client: tools = await client.list_tools() ``` @@ -251,7 +257,7 @@ app = Starlette(routes=[Mount("/", app=mcp.streamable_http_app())], lifespan=lif ## `MCPError: Session not found` {#mcperror-session-not-found} -O servidor não reconhece o `Mcp-Session-Id` que o seu cliente enviou, quase sempre porque o servidor **reiniciou** (ou você foi roteado para uma instância diferente). As sessões vivem na memória daquele único processo. +O servidor não reconhece o `Mcp-Session-Id` que o seu cliente enviou. Ou o servidor **reiniciou** (ou você foi roteado para uma instância diferente), ou a sessão **expirou** porque nada estava em andamento durante o `session_idle_timeout`, que é de 30 minutos por padrão. Veja [Tempo de vida e limites da sessão](run/legacy-clients.md#session-lifetime-and-limits). As sessões vivem na memória daquele único processo. Não há bug de servidor para encontrar. A resposta HTTP é um `404` cujo corpo *é* JSON-RPC, então, ao contrário do `421` acima, o `Client` python mostra esta aqui palavra por palavra: @@ -261,9 +267,9 @@ Não há bug de servidor para encontrar. A resposta HTTP é um `404` cujo corpo A correção é reconectar: saia do bloco `async with Client(...)` e entre em um novo, que negocia uma sessão nova. Para um cliente de vida longa, isso significa capturar `MCPError` em volta das suas chamadas e reconectar ao ver esta mensagem, em vez de tentar de novo dentro de uma sessão morta. -Se isso acontece *sem* um reinício, você está rodando mais de um worker sem sticky sessions: cada worker mantém a própria tabela de sessões, então uma requisição roteada para o errado cai aqui. **[Deploy e escala](run/deploy.md)** e **[Atendendo clientes legados](run/legacy-clients.md)** são donos dessa história e das suas duas correções (roteamento sticky, ou `stateless_http=True`). +Se isso acontece *sem* um reinício e sem que o cliente tenha ficado quieto por tanto tempo, você está rodando mais de um worker sem sticky sessions: cada worker mantém a própria tabela de sessões, então uma requisição roteada para o errado cai aqui. **[Deploy e escala](run/deploy.md)** e **[Atendendo clientes legados](run/legacy-clients.md)** são donos dessa história e das suas duas correções (roteamento sticky, ou `stateless_http=True`). -Para quem opera o servidor, a linha de log correspondente é `Rejected request with unknown or expired session ID: `. Ela é registrada em `INFO`, então é invisível no limite usual de `WARNING`. Vê-la em rajadas logo depois de um deploy é normal; todo cliente conectado está reconectando. +Para quem opera o servidor, a linha de log correspondente é `Rejected request with unknown or expired session ID: `. Ela é registrada em `INFO`, então é invisível no limite usual de `WARNING`. Vê-la em rajadas logo depois de um deploy é normal; todo cliente conectado está reconectando. Quando, em vez disso, a sessão expirou, essa linha vem precedida de `Session idle timeout`, também em `INFO`. ## `MCPError: Method not found` {#mcperror-method-not-found} @@ -275,7 +281,13 @@ Uma coisa **não** produz este erro, apesar de ser uma requisição que o protoc Seu servidor quer perguntar algo ao usuário, e este cliente nunca disse que pode receber perguntas. -Um resolvedor de elicitação (elicitation) recusa logo de início quando o cliente conectado não declarou elicitação por formulário, e `e.error.data` nomeia exatamente o que falta: +Este Bistro pergunta antes de reservar, por meio de um resolvedor: + +```python title="server.py" hl_lines="15-17 21" +--8<-- "docs_src/troubleshooting/tutorial007.py" +``` + +Sirva-o no lugar do servidor Weather e chame `book_table` a partir de um cliente que não passou nenhum `elicitation_callback`. O resolvedor recusa logo de início, porque o cliente conectado nunca declarou elicitação (elicitation) por formulário, e `e.error.data` nomeia exatamente o que falta: ```json { @@ -289,7 +301,7 @@ Passe `elicitation_callback=` para `Client(...)`. Registrar o callback *é* a de ```python async def main() -> None: - async with Client(mcp, elicitation_callback=handle_elicitation) as client: + async with Client("http://localhost:8000/mcp", elicitation_callback=handle_elicitation) as client: result = await client.call_tool("book_table", {"date": "Friday"}) ``` @@ -314,14 +326,14 @@ Você vê esta a partir de `ctx.elicit()` em uma conexão legada, e em qualquer Seu handler tentou alcançar o cliente no meio da requisição, em uma conexão cuja chamada não tem canal capaz de carregar uma requisição vinda do servidor. Há três configurações de servidor que colocam uma chamada nessa situação. -**Uma conexão `2026-07-28`: qualquer transporte, sempre.** O protocolo moderno não tem nenhuma requisição iniciada pelo servidor, então o servidor recusa antes que qualquer coisa seja enviada. `ctx.elicit()` dentro de uma ferramenta é o jeito clássico de topar com isso (logo no primeiro teste em memória, já que `Client(server)` negocia `2026-07-28` sem que ninguém peça), e passar `elicitation_callback=` não muda nada, porque nenhuma requisição chega ao cliente para ele responder: +**Uma conexão `2026-07-28`: qualquer transporte, sempre.** O protocolo moderno não tem nenhuma requisição iniciada pelo servidor, então o servidor recusa antes que qualquer coisa seja enviada. `ctx.elicit()` dentro de uma ferramenta é o jeito clássico de topar com isso, em geral logo no primeiríssimo **[teste](get-started/testing.md)** em memória dessa ferramenta, já que `Client(mcp)` negocia `2026-07-28` sem que ninguém peça. Passar `elicitation_callback=` não muda nada, porque nenhuma requisição chega ao cliente para ele responder: ```python title="server.py" hl_lines="16" --8<-- "docs_src/troubleshooting/tutorial006.py" ``` ```python -async def main() -> None: +async def test_book_table() -> None: async with Client(mcp) as client: await client.call_tool("book_table", {"date": "Friday"}) ``` @@ -363,7 +375,7 @@ O servidor não conseguiu verificar o token `requestState` que o seu cliente dev ```python async def main() -> None: - async with Client(mcp) as client: + async with Client("http://localhost:8000/mcp") as client: await client.call_tool("forecast", {"city": "London"}, request_state="round-1-from-worker-a") ``` @@ -416,7 +428,7 @@ mcp = MCPServer("Weather", request_state_security=RequestStateSecurity(keys=[key * `Tool already exists:` no log do servidor é o único sinal de que duas ferramentas com o mesmo nome viraram uma só. * Um 421, três grafias: `Server returned an error response` (o `Client` python), `421 Misdirected Request` / `Invalid Host header` (todo o resto), `Invalid Host header: ` (o log do servidor). Correção: `transport_security=TransportSecuritySettings(allowed_hosts=[...])`. * `Task group is not initialized` -> um app montado cujo lifespan do host nunca entrou em `mcp.session_manager.run()`. -* `Session not found` -> o servidor reiniciou; reconecte. +* `Session not found` -> o servidor reiniciou ou a sessão expirou (`session_idle_timeout`); reconecte. * `Cannot send 'elicitation/create': ... no back-channel ...` -> `ctx.elicit()` precisa de um canal do servidor para o cliente: uma conexão `2026-07-28` nunca tem um, `stateless_http=True` tira o legado, e `json_response=True` tira o de escopo de requisição. Use um resolvedor (um cliente legado também precisa de um servidor que mantenha o canal). O vizinho `Method not found` é uma requisição para um método que a revisão do protocolo do outro lado não tem. * `Client did not declare the form elicitation capability ...` e `Elicitation not supported` -> falta `elicitation_callback=` no cliente. * `Invalid or expired requestState` nunca diz o porquê na rede. O log do servidor diz; `unknown key` significa compartilhar `RequestStateSecurity(keys=[...])` entre os workers. diff --git a/i18n/pt/pages/whats-new.md b/i18n/pt/pages/whats-new.md index 6957e001..8c68702e 100644 --- a/i18n/pt/pages/whats-new.md +++ b/i18n/pt/pages/whats-new.md @@ -1,6 +1,6 @@ --- translation: - sections: [cfe01c0c5863dfa2, 1c58c5cfcc37d455, a7392996acf1ad8f, 875eb2889263424e] + sections: [cfe01c0c5863dfa2, 0dbb68d8b210177b, 80cf193023af3ed4, 875eb2889263424e] tool: 1 --- # O que há de novo na v2 {#whats-new-in-v2} @@ -43,11 +43,11 @@ Nem tudo de que uma ferramenta (tool) precisa deve vir do modelo. Novidade na v2 A v1 entregava três camadas aninhadas: um gerenciador de contexto de transporte que produzia streams brutos, uma `ClientSession` em volta deles e um `await session.initialize()` chamado à mão. A v2 tem um objeto só: -```python title="client.py" hl_lines="14-18" ---8<-- "docs_src/client/tutorial001.py" +```python title="client.py" hl_lines="7-11" +--8<-- "docs_src/client/tutorial001_client.py" ``` -`Client` recebe um objeto de servidor (em memória, sem transporte: é o cenário dos testes), uma URL (Streamable HTTP), um `StdioServerParameters` (um subprocesso stdio) ou qualquer outro gerenciador de contexto de transporte, como `sse_client(...)`. Entrar no `async with` conecta e negocia a versão do protocolo, seja qual for a era que o servidor fale; `client.server_capabilities` e `client.protocol_version` simplesmente estão lá depois disso, e `client.server_info` também, quando o servidor se identifica (agora ele é `Implementation | None`, já que na era 2026 a identidade é opcional). Os callbacks de amostragem e de elicitação que você registrou na v1 continuam funcionando (o corpo deles passa pela mesma renomeação de atributos para snake_case que todo o resto desta página), agora também respondem às requisições-dentro-de-resultados no estilo 2026 (abaixo), e rodam de forma concorrente em vez de um por vez. `ClientSession` continua por baixo para quem quer a superfície de baixo nível, e `client.session` a entrega para você; ela também mudou (roda sobre o novo motor de dispatcher, e algumas das próprias assinaturas dela mudaram), então leia o **[Guia de migração](migration.md#clientsession-now-runs-on-jsonrpcdispatcher-basesession-removed)** antes de descer de nível. +`Client` recebe uma URL (Streamable HTTP), um `StdioServerParameters` (um subprocesso stdio), qualquer outro gerenciador de contexto de transporte, como `sse_client(...)`, ou, nos testes, o próprio objeto do servidor (em memória, sem transporte). Entrar no `async with` conecta e negocia a versão do protocolo, seja qual for a era que o servidor fale; `client.server_capabilities` e `client.protocol_version` simplesmente estão lá depois disso, e `client.server_info` também, quando o servidor se identifica (agora ele é `Implementation | None`, já que na era 2026 a identidade é opcional). Os callbacks de amostragem e de elicitação que você registrou na v1 continuam funcionando (o corpo deles passa pela mesma renomeação de atributos para snake_case que todo o resto desta página), agora também respondem às requisições-dentro-de-resultados no estilo 2026 (abaixo), e rodam de forma concorrente em vez de um por vez. `ClientSession` continua por baixo para quem quer a superfície de baixo nível, e `client.session` a entrega para você; ela também mudou (roda sobre o novo motor de dispatcher, e algumas das próprias assinaturas dela mudaram), então leia o **[Guia de migração](migration.md#clientsession-now-runs-on-jsonrpcdispatcher-basesession-removed)** antes de descer de nível. **[O Client](client/index.md)** o apresenta, **[Transportes do cliente](client/transports.md)** cobre as quatro formas de conexão, **[Callbacks do cliente](client/callbacks.md)** cobre os callbacks em si, e **[Testes](get-started/testing.md)** mostra o padrão em memória que substitui o helper `create_connected_server_and_client_session()` da v1. @@ -172,11 +172,15 @@ Toda requisição iniciada pelo servidor sumiu na 2026-07-28: elicitação por p A substituição inverte a chamada. Uma ferramenta que precisa de algo do usuário *retorna* a pergunta (`InputRequiredResult`), o cliente a responde com os mesmos callbacks que sempre teve, e a chamada é repetida com as respostas anexadas. `Client` conduz esse loop para você. No servidor você raramente monta o resultado por conta própria, porque uma **[dependência](handlers/dependencies.md)** faz isso: anote um parâmetro com `Resolve(ask_quantity)`, onde `ask_quantity` é uma função comum que você escreve, e o SDK pergunta pelo mecanismo que a conexão suportar, uma requisição de elicitação ao vivo em uma sessão legada ou um multi-round-trip na 2026. Um corpo de ferramenta, as duas eras: -```python title="dual_era.py" hl_lines="24 37-38" +```python title="server.py" hl_lines="21" --8<-- "docs_src/legacy_clients/tutorial001.py" ``` -Esse arquivo é a proposta inteira em um lugar só: um servidor, uma ferramenta apoiada em `Resolve`, e um cliente legado mais um cliente moderno, os dois recebendo a sua resposta, em memória. **[Requisições multi-round-trip](handlers/multi-round-trip.md)** explica o mecanismo (incluindo o `request_state`, que o SDK sela e verifica para você); **[Elicitação](handlers/elicitation.md)** cobre a parte de perguntar. +```python title="client.py" hl_lines="14-15" +--8<-- "docs_src/legacy_clients/tutorial001_client.py" +``` + +Esses dois arquivos são a proposta inteira: um servidor, uma ferramenta apoiada em `Resolve`, e um cliente legado mais um cliente moderno, os dois recebendo a sua resposta do mesmo servidor em execução (**[Servindo clientes legados](run/legacy-clients.md)** percorre os dois). **[Requisições multi-round-trip](handlers/multi-round-trip.md)** explica o mecanismo (incluindo o `request_state`, que o SDK sela e verifica para você); **[Elicitação](handlers/elicitation.md)** cobre a parte de perguntar. !!! warning "Este é o único lugar em que um servidor v1 portado muda de comportamento" Os seus próprios testes esbarram nisso primeiro: `Client(mcp)` negocia 2026-07-28 com o seu diff --git a/i18n/ru/pages/advanced/apps.md b/i18n/ru/pages/advanced/apps.md index c4de3c2d..772437d0 100644 --- a/i18n/ru/pages/advanced/apps.md +++ b/i18n/ru/pages/advanced/apps.md @@ -1,6 +1,6 @@ --- translation: - sections: [0355618e5f4d5fe4, 1821eaf50f2d0b64, 82e0b28ebd3abf5a, 8ac39614c094f2d0, dab6ff945501ab2a, bd5565c3b2d4f959, 96819ce3d63a0487] + sections: [0355618e5f4d5fe4, 0fefa31fb7c7b585, 5c53e7487c9c70cc, 8ac39614c094f2d0, dab6ff945501ab2a, bd5565c3b2d4f959, 96819ce3d63a0487] tool: 1 --- # MCP Apps {#mcp-apps} @@ -18,7 +18,7 @@ translation: ## Часы с циферблатом {#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" ``` @@ -39,11 +39,31 @@ translation: Модель читает `content`; iframe — для людей. Хост с поддержкой UI всё равно передаёт текстовый результат модели, а чисто текстовый клиент получает *только* его. Поэтому канонический паттерн — один инструмент, два ответа. Взгляните на `get_time` ещё раз: -```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)` возвращает `True`, только когда клиент объявил расширение `io.modelcontextprotocol/ui` **и** указал `text/html;profile=mcp-app` в настройке `mimeTypes`. Поле обязательное, так что клиент, который его опустил, не считается. Именно это объявляет `main()` в том же файле: клиентскую половину согласования — и в ответ приходит развёрнутый результат. +`client_supports_apps(ctx)` возвращает `True`, только когда клиент объявил расширение `io.modelcontextprotocol/ui` **и** указал `text/html;profile=mcp-app` в настройке `mimeTypes`. Поле обязательное, так что клиент, который его опустил, не считается. Вот клиентская половина согласования: + +```python title="client.py" hl_lines="8 12" +--8<-- "docs_src/apps/tutorial001_client.py" +``` + +Запустите `server.py` по HTTP, затем во втором терминале запустите клиент: + +```console +uv run mcp run server.py --transport streamable-http +``` + +```console +python client.py +``` + +```text +2026-06-26T12:00:00Z +``` + +Пришёл развёрнутый ответ. Уберите `extensions=[APPS_SUPPORT]` из вызова `Client` — и та же программа напечатает `The time is 2026-06-26T12:00:00Z.`: это всё, что когда-либо увидит чисто текстовый клиент. !!! warning Никогда не возвращайте заглушку вроде `"[Rendered UI]"` в качестве единственного содержимого. Если запасной текст бесполезен, инструмент бесполезен для любого текстового клиента и для самой модели. Напишите нормальное предложение. diff --git a/i18n/ru/pages/advanced/extensions.md b/i18n/ru/pages/advanced/extensions.md index a2d23367..688c4a2d 100644 --- a/i18n/ru/pages/advanced/extensions.md +++ b/i18n/ru/pages/advanced/extensions.md @@ -1,6 +1,6 @@ --- translation: - sections: [05891e7cc1938a13, b3c01a6af28c51ee, 7ffc91f5e38bdfe0, 717d3f235a8333a7, f471a13b2fe5d737, ed6af2df4b656dff] + sections: [05891e7cc1938a13, b3c01a6af28c51ee, 6eba6ce094f4417e, 125f28588c85dd7b, ddd8969b698ca11c, ed6af2df4b656dff] tool: 1 --- # Расширения {#extensions} @@ -49,7 +49,7 @@ TypeError: Stamps.identifier must be a `vendor-prefix/name` string Самое маленькое полезное расширение — один инструмент и карта настроек: -```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" ``` @@ -57,17 +57,23 @@ TypeError: Stamps.identifier must be a `vendor-prefix/name` string * `settings()` — значение, объявляемое в `capabilities.extensions["com.example/stamps"]`. Верните `{}` (значение по умолчанию), чтобы объявить расширение без настроек. * Расширение никогда не получает сервер. Оно описывает свой вклад как данные; `MCPServer` их потребляет. Никакого `self.server`, который можно было бы менять, нет. -А `main()` служит доказательством: клиент в памяти, подключённый напрямую к `mcp`: +Запустите его по HTTP — и доказательством послужит клиент: -```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" +``` + +Каждый `server.py` на этой странице запускается этой командой, а каждый `client.py` работает рядом с ним: `python client.py` во втором терминале. + ### Обслуживание собственных методов {#serving-your-own-methods} Расширение может регистрировать **новые методы запросов** — собственные глаголы, обслуживаемые рядом с методами спецификации: -```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" ``` @@ -83,14 +89,15 @@ TypeError: Stamps.identifier must be a `vendor-prefix/name` string ### Клиентская сторона {#the-client-side} -`main()` из того же файла — это вся клиентская часть, обе её половины: +Клиент — отдельная программа, и в ней обе половины клиентской части: -```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)])` объявляет расширение. Объявления превращаются в `ClientCapabilities.extensions`: на подключении версии 2026-07-28 карта передаётся в конверте `_meta` каждого запроса, так что сервер видит её в **каждом** запросе; на подключении старого поколения она едет в рукопожатии `initialize`. Серверному коду всё равно, какой из вариантов: `require_client_extension(ctx, ...)` и `ctx.session.check_client_capability(...)` читают нужный источник на обоих путях. * Вендорные методы спускаются на уровень ниже, к `client.session.send_request(...)`; `Client` обзаводится полноценными методами только для глаголов спецификации. `send_request` принимает любой подкласс `Request`, так что вендорный запрос проходит как есть. +* `SearchRequest` и две модели, которые он несёт, — это контракт расширения в передаваемых данных, поэтому клиент объявляет их у себя сам. Опубликованное расширение поставляло бы их в пакете, который импортируют обе стороны. ### Перехват `tools/call` {#intercepting-toolscall} @@ -109,12 +116,18 @@ TypeError: Stamps.identifier must be a `vendor-prefix/name` string ## Использование клиентского расширения {#using-a-client-extension} -**Клиентское расширение** — тот же контракт со стороны потребителя: набор клиентского поведения за одним идентификатором. Передайте экземпляры в `Client(extensions=[...])` и вызывайте инструменты как обычно: +**Клиентское расширение** — тот же контракт со стороны потребителя: набор клиентского поведения за одним идентификатором. Сервер здесь отвечает на `buy` не самим товаром, а квитанцией, которую нужно погасить, — и только клиенту, объявившему расширение: -```python title="client.py" hl_lines="66-68" +```python title="server.py" hl_lines="22-25" --8<-- "docs_src/extensions/tutorial006.py" ``` +На клиенте передайте экземпляры в `Client(extensions=[...])` и вызывайте инструменты как обычно: + +```python title="client.py" hl_lines="33-35" +--8<-- "docs_src/extensions/tutorial006_client.py" +``` + `call_tool("buy", ...)` возвращает обычный `CallToolResult`, как и любой другой вызов. Что изменило расширение: теперь сервер может ответить на `buy` **формой результата** `receipt` вместо окончательного результата, а `Receipts` доводит её до конца (здесь — погашая квитанцию дополнительным вызовом) до того, как `call_tool` вернёт управление. В месте вызова не меняется ничего. Уберите расширение — и ничего этого не будет: пропускной пункт сервера отклонит клиент, который его не объявил (ошибка -32021), а заявленная форма от сервера, пропускающего эту проверку, не пройдёт валидацию — ровно так, как спецификация требует для нераспознанного `resultType`. Выключено по умолчанию, на обоих концах соединения. @@ -124,15 +137,15 @@ TypeError: Stamps.identifier must be a `vendor-prefix/name` string ```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} Унаследуйтесь от `ClientExtension` и переопределите только то, что нужно. Три вида вклада, у каждого реализация по умолчанию: `settings()`, `claims()` и `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" ``` * Идентификатор подчиняется той же грамматике, что и на сервере, и проверяется при определении класса. @@ -153,12 +166,18 @@ def notifications(self) -> Sequence[NotificationBinding[Any]]: ### Глаголы расширения {#extension-verbs} -Собственные методы запросов расширения не требуют регистрации на стороне клиента. Тип вендорного запроса наследуется от `mcp.types.Request` и отправляется через `client.session.send_request`, как в разделе [Обслуживание собственных методов](#serving-your-own-methods). Одно дополнение: когда ключ из params должен передаваться в заголовке `Mcp-Name` (спецификации расширений, например tasks, требуют этого для своих глаголов), тип запроса объявляет `name_param`: +Собственные методы запросов расширения не требуют регистрации на стороне клиента. Тип вендорного запроса наследуется от `mcp.types.Request` и отправляется через `client.session.send_request`, как в разделе [Обслуживание собственных методов](#serving-your-own-methods). Возьмём сервер, расширение которого обслуживает один глагол, относящийся к именованному заданию: -```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" ``` +Одно дополнение на клиенте: когда ключ из params должен передаваться в заголовке `Mcp-Name` (спецификации расширений, например tasks, требуют этого для своих глаголов), тип запроса объявляет `name_param`: + +```python title="client.py" hl_lines="20-23 28-29" +--8<-- "docs_src/extensions/tutorial007_client.py" +``` + Сессия дублирует `params["jobId"]` в `Mcp-Name` на каждом пути отправки, а отсутствующее значение приводит к явной ошибке, а не к молчаливому пропуску обязательного заголовка. ## Чего расширение не может {#what-an-extension-cannot-do} diff --git a/i18n/ru/pages/advanced/low-level-server.md b/i18n/ru/pages/advanced/low-level-server.md index a982fb0f..2bd34e60 100644 --- a/i18n/ru/pages/advanced/low-level-server.md +++ b/i18n/ru/pages/advanced/low-level-server.md @@ -1,6 +1,6 @@ --- translation: - sections: [2c79b6338e09b7ac, 7edc43b3fae11314, 1086e77ce561cd7f, a3f71823df5efc31, 9fc7109f72201cae, d50fe7faead8cf68, 7bf25983df655b66, 6330e1f4c6029683, 2f1749c8c133fa1c, 8db7116fc8ddd0ee, ebc33704fbd74262, cd0e9c933350390e] + sections: [2c79b6338e09b7ac, 9d5d10a5f0405d0a, 1086e77ce561cd7f, a3f71823df5efc31, 9fc7109f72201cae, d50fe7faead8cf68, 7bf25983df655b66, 6330e1f4c6029683, 2f1749c8c133fa1c, 8db7116fc8ddd0ee, ebc33704fbd74262, 0fde3bcea081ba3a] tool: 1 --- # Низкоуровневый Server {#the-low-level-server} @@ -36,18 +36,22 @@ translation: ### Попробуйте сами {#try-it} -Inspector здесь не поможет: `mcp dev` и `mcp run` принимают только `MCPServer`. Клиенту `Client`, работающему в памяти, всё равно — он принимает низкоуровневый `Server` точно так же, как `MCPServer`: +`mcp dev` и `mcp run` принимают только `MCPServer`, так что этот сервер вы запускаете сами. Последняя строка `server.py` строит из него обычное ASGI-приложение, а uvicorn его запускает: -```python title="main.py" +```console +uvicorn server:app --port 8000 +``` + +Направьте Inspector или любой клиент на `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) @@ -64,6 +68,8 @@ asyncio.run(main()) * `result.structured_content` равен `None`. Высокоуровневый сервер сам оборачивает `-> str` в `{"result": ...}`; здесь никто не соберёт то, чего не собрали вы. * `list_tools` возвращает схему, которую набрали **вы**, символ в символ. В высокоуровневой версии у каждого свойства было `"title": "Query"`, а в корне — `"title": "search_booksArguments"`: артефакты Pydantic. Здесь всё, что есть в передаваемых данных, положили туда вы. +В тесте uvicorn и порт не нужны: `Client(server)` принимает низкоуровневый `Server` внутри процесса точно так же, как `MCPServer`, и именно этот подход описан на странице **[Тестирование](../get-started/testing.md)**. + ## За вас ничего не проверяют {#nothing-is-checked-for-you} `MCPServer` отклоняет некорректный аргумент ещё до запуска вашей функции, проверяя вызов по сгенерированной им схеме (**[Инструменты](../servers/tools.md)**). @@ -215,4 +221,4 @@ use Server.middleware to observe or wrap initialization * `add_request_handler(method, params_type, handler)` обслуживает любой метод. `initialize` зарезервирован. * Возможности, которые объявляет `Server`, выводятся из того, какие обработчики вы зарегистрировали. -`Client(server)` обращался с обоими серверами одинаково, потому что это *и есть* один и тот же протокол — в этом весь смысл. Следующий уровень вниз — вообще не класс: это **[Middleware](middleware.md)**. +Клиент обращался с обоими серверами одинаково, потому что это *и есть* один и тот же протокол — в этом весь смысл. Следующий уровень вниз — вообще не класс: это **[Middleware](middleware.md)**. diff --git a/i18n/ru/pages/advanced/pagination.md b/i18n/ru/pages/advanced/pagination.md index adc35963..dba7d7bd 100644 --- a/i18n/ru/pages/advanced/pagination.md +++ b/i18n/ru/pages/advanced/pagination.md @@ -1,6 +1,6 @@ --- translation: - sections: [a9aba7a026c7bd85, ed32bda7ba9ae33a, 7e64cc5646abb91f, 22a0129ee78b3c63, d875373c06d8d2f9] + sections: [a9aba7a026c7bd85, 83e2a08b9d46a398, 9fd8a0aa384b3257, 22a0129ee78b3c63, d875373c06d8d2f9] tool: 1 --- # Пагинация {#pagination} @@ -31,9 +31,13 @@ translation: ### Попробуйте сами {#try-it} -`Client(server)` подключается к низкоуровневому `Server` в памяти точно так же, как к `MCPServer`. +`mcp run` принимает только `MCPServer`, поэтому этот сервер придётся запускать самостоятельно. Последняя строка `server.py` собирает из `Server` обычное ASGI-приложение, и его запускает uvicorn: -Вызовите `list_resources()` без аргументов. Придут десять ресурсов, от `book-1` до `book-10`, а `next_cursor` будет строкой `"10"`. +```console +uvicorn server:app --port 8000 +``` + +Направьте любой клиент (**[Клиент](../client/index.md)** или Inspector) на `http://localhost:8000/mcp` и вызовите `list_resources()` без аргументов. Придут десять ресурсов, от `book-1` до `book-10`, а `next_cursor` будет строкой `"10"`. Передайте его обратно через `list_resources(cursor="10")` — первым ресурсом окажется `book-11`, а новый `next_cursor` будет `"20"`. @@ -43,7 +47,7 @@ translation: Каждый метод `list_*` у `Client` (`list_tools`, `list_resources`, `list_resource_templates`, `list_prompts`) принимает именованный аргумент `cursor=`. Вычитать список постранично целиком — это один `while True`: -```python title="client.py" hl_lines="26-32" +```python title="client.py" hl_lines="9-15" --8<-- "docs_src/pagination/tutorial002.py" ``` @@ -51,7 +55,7 @@ translation: * Добавляйте элементы **до** того, как смотреть на `next_cursor`: на последней странице тоже есть ресурсы. * `next_cursor is None` — условие выхода. Всё остальное без изменений отправляется обратно в `cursor=`. -Запустите его `main()`, и он напечатает `100 resources`: десять страниц по десять, сшитых циклом, который и не знал, что страниц было десять. +Пока uvicorn продолжает обслуживать `server.py`, запустите `python client.py` во втором терминале. Он напечатает `100 resources`: десять страниц по десять, сшитых циклом, который и не знал, что страниц было десять. Это тот же цикл, который **[Клиент](../client/index.md)** показывает для каждого метода `list_*`, и против сервера без пагинации он ничего не стоит: `next_cursor` равен `None` уже в первом ответе, и цикл выполняется один раз. diff --git a/i18n/ru/pages/client/caching.md b/i18n/ru/pages/client/caching.md index f23dbc7b..fa35cdcc 100644 --- a/i18n/ru/pages/client/caching.md +++ b/i18n/ru/pages/client/caching.md @@ -1,6 +1,6 @@ --- translation: - sections: [9e7b9a1710e5aeba, b74ca4c1d2ddddee, fa8714e61bf90c5a, 04db67a886b7271c, 857690fb8f876800] + sections: [9e7b9a1710e5aeba, 66a2e9acc9101d54, d3d25aa802f5145a, 04db67a886b7271c, 857690fb8f876800] tool: 1 --- # Подсказки по кэшированию {#caching-hints} @@ -30,7 +30,7 @@ translation: В низкоуровневом классе `Server` обработчики собирают результаты вручную, а `ttl_ms` и `cache_scope` — это просто поля моделей результата. Обработчик, который задаёт их явно, всегда берёт верх над словарём из конструктора, поле за полем: -```python title="server.py" hl_lines="10 16" +```python title="server.py" hl_lines="11 17" --8<-- "docs_src/caching/tutorial002.py" ``` @@ -44,10 +44,24 @@ translation: В сессии 2026-07-28 `Client` учитывает подсказки за вас: у него есть встроенный кэш ответов, включённый по умолчанию. Результат, пришедший с `ttlMs`, сохраняется, и идентичный вызов в пределах этого TTL обслуживается из кэша без обращения к серверу. Результат *без* подсказки не кэшируется: результаты без подсказок получают `CacheConfig.default_ttl_ms`, по умолчанию равный `0` (сразу устаревший), так что сервер, ничего не объявляющий, видит ровно тот же трафик — вызов за вызовом, — что и всегда. -```python title="client.py" hl_lines="33 35 38" +Чтобы увидеть это в действии, запустите `server.py` из предыдущего раздела под uvicorn (его последняя строка собирает ASGI-приложение). Обработчик печатает строку каждый раз, когда действительно выполняется: + +```console +uvicorn server:app --port 8000 +``` + +```python title="client.py" hl_lines="20 23 28" --8<-- "docs_src/caching/tutorial003.py" ``` +Запустите `python client.py` во втором терминале. Он напечатает подсказки, с которыми пришёл первый результат: `ttlMs` обработчика рядом с `cacheScope` из словаря: + +```text +1000 public +``` + +Остальное видно в терминале сервера: между логами запросов uvicorn строка `tools/list served` появляется три раза. + Четыре вызова, три обращения к серверу. Второй вызов нашёл свежую запись и до сервера не дошёл; перевод (внедрённых) часов за пределы TTL заставил третий снова обратиться к серверу; четвёртый указал `cache_mode="refresh"`. Этот именованный аргумент есть у пяти кэширующих методов (`list_tools`, `list_prompts`, `list_resources`, `list_resource_templates`, `read_resource`): * `"use"` (по умолчанию) отдаёт свежую запись, если она есть, а если нет — запрашивает и сохраняет результат. @@ -56,7 +70,7 @@ translation: Над `"use"` стоит одно правило: **вызовы с `meta` всегда доходят до сервера.** Запрос с заданным `meta` (токен прогресса, поля трассировки) рассчитывает на настоящий сетевой запрос, поэтому при `cache_mode="use"` он обрабатывается как `"refresh"`: чтение из кэша пропускается, а полученный результат всё равно заменяет закэшированную запись. `"bypass"` и явный `"refresh"` ведут себя как обычно. -Чтобы совсем выключить кэширование, создайте клиент как `Client(server, cache=None)`: каждый вызов снова обращается к серверу, а `cache_mode`, хотя и принимается, ничего не делает. +Чтобы совсем выключить кэширование, передайте `cache=None` при создании `Client`: каждый вызов снова обращается к серверу, а `cache_mode`, хотя и принимается, ничего не делает. Область тоже учитывается автоматически: записи `"private"` привязаны к *разделу* (partition) кэша (о нём ниже), тогда как записи `"public"` могут быть разделены шире. И **уведомления важнее TTL** для ровно тех записей, которые они называют: уведомление `list_changed` вытесняет соответствующий закэшированный список, а `resources/updated` вытесняет закэшированное чтение, сохранённое ровно под его URI, какими бы свежими они ни были. На подключении 2026-07-28 эти уведомления приходят по потоку `subscriptions/listen`, который открывается через `client.listen(...)`, и вытеснение завершается раньше, чем наблюдатель увидит событие; подробнее — на странице **[Подписки](subscriptions.md)**. diff --git a/i18n/ru/pages/client/callbacks.md b/i18n/ru/pages/client/callbacks.md index c0c29f50..c5392146 100644 --- a/i18n/ru/pages/client/callbacks.md +++ b/i18n/ru/pages/client/callbacks.md @@ -1,6 +1,6 @@ --- translation: - sections: [adf3c545b5be46b6, 916cd3ab1c03f461, e9be7a8d0eb0a456, 565890a636288ecf, 6af7e49db9129ec3, 06b0238c174186af, 90c6043be435fcb0] + sections: [adf3c545b5be46b6, 916cd3ab1c03f461, 32ef568335dd95a7, 565890a636288ecf, 6af7e49db9129ec3, 06b0238c174186af, 0abc5ea5cb7ff6b3] tool: 1 --- # Колбэки клиента {#client-callbacks} @@ -63,7 +63,7 @@ result.content # [TextContent(type='text', text='Card issued to Ada Lovelace.') `mode="legacy"` в вызове `Client(...)` стоит не просто так. По умолчанию `Client(...)` согласовывает современный вариант протокола, а в нём нет обратного канала (back-channel) для запросов от сервера к клиенту: `ctx.elicit` завершается ошибкой ещё до того, как колбэк успевает запуститься. Решает это не транспорт, а согласованный - протокол — и в памяти, и по URL одинаково. Фиксируйте `mode="legacy"` всякий раз, когда клиент должен + протокол. Фиксируйте `mode="legacy"` всякий раз, когда клиент должен отвечать на такие запросы; так делает каждый тест, стоящий за этой страницей. Подробнее — на странице **[Версии протокола](../protocol-versions.md)**. В сессии 2026-07-28 колбэк не бесполезен — просто данные поступают к нему иначе: когда инструмент возвращает @@ -151,4 +151,4 @@ result.structured_content # {'result': ['elicitation']} * `sampling_callback` и `list_roots_callback` работают так же, но обслуживают устаревшие возможности; современные серверы вместо этого используют многораундовые запросы. * `logging_callback` и `message_handler` получают уведомления. Они ничего не объявляют. -Первый аргумент `Client(...)` — объект транспорта. Все их виды описаны на странице **[Транспорты клиента](transports.md)**. +Первый аргумент `Client(...)` выбирает транспорт. Все их виды описаны на странице **[Транспорты клиента](transports.md)**. diff --git a/i18n/ru/pages/client/index.md b/i18n/ru/pages/client/index.md index 5a86d3dc..d653c886 100644 --- a/i18n/ru/pages/client/index.md +++ b/i18n/ru/pages/client/index.md @@ -1,6 +1,6 @@ --- translation: - sections: [ebef1e7a0df854f4, 8355cfaf1f76c9d5, 8e79141fc2985342, 46bdb07c7537e8a5, 80ce41579825a6fa, 5f0fa90494de8f65, 83d10514eaa62fa5, 9190555aa39a5d28, 84a4c9d8bf14dddb, 927d71cf40b58c30] + sections: [ebef1e7a0df854f4, 7e16449f66e7dfd6, eeb0682f7d2a1079, 5713f0196a34e6e7, 0e844597859e4248, 3a97d9195ddcc92e, 1da08c483e59c141, 84702cc6e0a1fd42, 8dee7a31c86ffc5c, 83a5bce168ef23d7] tool: 1 --- # Объект Client {#the-client} @@ -11,13 +11,23 @@ translation: ## Первый клиент {#your-first-client} -```python title="client.py" hl_lines="14-18" +Клиенту нужен сервер, с которым он будет говорить. К этому серверу Bookshop подключается каждый пример на этой странице. Сохраните его как `server.py` и оставьте работать по HTTP: + +```python title="server.py" --8<-- "docs_src/client/tutorial001.py" ``` -Сервер в начале файла нужен лишь для того, чтобы было к чему подключаться. Клиент — это пять выделенных строк. +```console +uv run mcp run server.py --transport streamable-http +``` -* `Client(mcp)` получает **сам объект сервера**. Это транспорт в памяти: ни подпроцесса, ни порта, ни HTTP. Именно так подключается каждый пример на этой странице и каждый тест, который вы напишете. +Теперь он доступен по адресу `http://localhost:8000/mcp`. Клиент — отдельная программа. Сохраните её как `client.py` и запустите `python client.py` во втором терминале: + +```python title="client.py" hl_lines="7-11" +--8<-- "docs_src/client/tutorial001_client.py" +``` + +* `Client("http://localhost:8000/mcp")` получает **URL**, поэтому подключается по Streamable HTTP к серверу, который вы только что запустили. * `async with` — это **жизненный цикл**. Вход в блок подключает и согласовывает возможности; выход — отключает. Пары `connect()` / `close()` нет, и `Client` нельзя использовать повторно после завершения блока. * Внутри блока сведения о подключении уже доступны как обычные свойства. @@ -25,10 +35,10 @@ translation: `Client` принимает один позиционный аргумент и определяет транспорт по его типу: -* Экземпляр `MCPServer` (или низкоуровневого `Server`): подключение **внутри процесса**. -* Строка с URL (`Client("http://localhost:8000/mcp")`): Streamable HTTP, основной вариант для реального развёртывания. -* `StdioServerParameters`: команда, которая запускается как **подпроцесс**; общение с ним идёт через его stdin и stdout. +* Строка с URL (`Client("http://localhost:8000/mcp")`): Streamable HTTP, транспорт для реального развёртывания. +* `StdioServerParameters`: команда, которая запускается как локальный **подпроцесс**; общение с ним идёт через его stdin и stdout. * **Транспорт**: всё, что можно использовать как `async with ... as (read, write)`, например `streamable_http_client(url, http_client=...)` поверх вашего собственного HTTP-клиента. +* Экземпляр `MCPServer` (или низкоуровневого `Server`): подключение **внутри процесса**, без подпроцесса и без порта. Этот вариант — для тестов, и на нём строится страница **[Тестирование](../get-started/testing.md)**. Всё остальное на этой странице одинаково для всех четырёх вариантов. Заголовкам, подпроцессам, тайм-аутам и протоколу `Transport` посвящена отдельная страница: **[Транспорты клиента](transports.md)**. @@ -49,11 +59,11 @@ translation: ## Получение списка инструментов {#listing-tools} -```python title="client.py" hl_lines="15-20" +```python title="client.py" hl_lines="8-13" --8<-- "docs_src/client/tutorial002.py" ``` -`list_tools()` возвращает `ListToolsResult`; инструменты лежат в `.tools`. Каждый из них — полное определение, которое хост передал бы модели: +`list_tools()` возвращает `ListToolsResult`; инструменты лежат в `.tools`. Каждый из них — полное определение, которое хост передал бы модели. Вот первый: ```python tool.name # 'search_books' @@ -77,6 +87,8 @@ tool.description # 'Search the catalog by title or author.' Этой схемы достаточно и интерфейсу, чтобы отрисовать форму аргументов, и модели, чтобы сформировать корректные аргументы. +Второй инструмент, `lookup_book`, зарегистрирован без `title=`, поэтому его `tool.title` равен `None`. + !!! tip `title` необязателен, поэтому интерфейсу, показывающему инструменты человеку, приходится выбирать: `title`, если он есть, иначе `name`. `from mcp.shared.metadata_utils import get_display_name` делает именно это — @@ -86,7 +98,7 @@ tool.description # 'Search the catalog by title or author.' `call_tool(name, arguments)` запускает инструмент и возвращает `CallToolResult`. -```python title="client.py" hl_lines="27-34" +```python title="client.py" hl_lines="9-16" --8<-- "docs_src/client/tutorial003.py" ``` @@ -142,7 +154,7 @@ result.is_error # False Операции с ресурсами идут парами: два способа получить список и один способ прочитать. -```python title="client.py" hl_lines="22-31" +```python title="client.py" hl_lines="9-18" --8<-- "docs_src/client/tutorial004.py" ``` @@ -156,7 +168,7 @@ result.is_error # False ## Промпты {#prompts} -```python title="client.py" hl_lines="15-20" +```python title="client.py" hl_lines="8-13" --8<-- "docs_src/client/tutorial005.py" ``` @@ -181,7 +193,7 @@ message.content # TextContent(type='text', text='Recommend one poetry book from Сервер с обработчиком автодополнения может дополнять аргументы промптов и шаблонов ресурсов по мере того, как пользователь их вводит. -```python title="client.py" hl_lines="27-31" +```python title="client.py" hl_lines="9-13" --8<-- "docs_src/client/tutorial006.py" ``` @@ -194,21 +206,21 @@ message.content # TextContent(type='text', text='Recommend one poetry book from Каждый метод `list_*` принимает именованный аргумент `cursor=`, а каждый результат содержит `next_cursor`. Когда `next_cursor` равен `None`, у вас есть всё. -```python title="client.py" hl_lines="22-30" +```python title="client.py" hl_lines="7-15" --8<-- "docs_src/client/tutorial007.py" ``` -Этот цикл корректен для любого сервера. `MCPServer` возвращает всё одной страницей, так что `next_cursor` равен `None` и цикл выполняется один раз — поэтому в большинстве программ его и не пишут. О серверах, которые действительно разбивают выдачу на страницы, и о правилах, которым подчиняются курсоры, — на странице **[Пагинация](../advanced/pagination.md)**. +Функция `list_all_tools` корректна для любого сервера. `MCPServer` возвращает всё одной страницей, так что `next_cursor` равен `None` и цикл выполняется один раз — поэтому в большинстве программ его и не пишут. О серверах, которые действительно разбивают выдачу на страницы, и о правилах, которым подчиняются курсоры, — на странице **[Пагинация](../advanced/pagination.md)**. ## В тестах {#in-tests} -`Client(mcp)` без процесса и без порта — уже готовая тестовая обвязка для сервера. +Каждый `client.py` на этой странице обращался к `server.py` по HTTP. В тесте сеть не нужна: `Client` получает сам объект сервера — `from server import mcp`, затем `Client(mcp)`. Ни процесса, ни порта, и все описанные выше методы работают так же. -Для этого есть один специальный флаг конструктора: `Client(mcp, raise_exceptions=True)`. Он действует только на подключениях в памяти, а объясняет его и строит вокруг него весь подход страница **[Тестирование](../get-started/testing.md)**. +Для этого есть один специальный флаг конструктора: `Client(mcp, raise_exceptions=True)`. Он действует только на подключениях внутри процесса, а объясняет его и строит вокруг него весь подход страница **[Тестирование](../get-started/testing.md)**. ## Итоги {#recap} -* `Client(x)` подключается в памяти к объекту сервера, по Streamable HTTP — к строке с URL, а ко всему остальному — через транспорт. +* `Client(x)` подключается по Streamable HTTP к строке с URL, запускает подпроцесс для `StdioServerParameters`, напрямую входит в переданный транспорт, а в тестах принимает сам объект сервера. * `async with` — это весь жизненный цикл. Внутри него `server_capabilities` и `protocol_version` уже заполнены; `server_info` и `instructions` — тоже, если сервер их предоставляет. * `list_tools()` даёт `name`, `title`, `description` и `input_schema` каждого инструмента. * `call_tool()` возвращает `content` для модели, `structured_content` для кода и `is_error`. Инструмент, выбросивший исключение, — это результат, а не исключение. diff --git a/i18n/ru/pages/client/oauth-clients.md b/i18n/ru/pages/client/oauth-clients.md index 3af177a2..987455f0 100644 --- a/i18n/ru/pages/client/oauth-clients.md +++ b/i18n/ru/pages/client/oauth-clients.md @@ -1,6 +1,6 @@ --- translation: - sections: [c6899d3892bd9fa0, 79372cff3cc48a88, 63878d29e87c3e73, 13175843d3588af4, e7e2b9fd516f77de, 758f06399b513c1f, a05d7278487d610b] + sections: [c6899d3892bd9fa0, 79372cff3cc48a88, c2dae1ebe2ebd543, 13175843d3588af4, df06056fb16b3846, 758f06399b513c1f, a05d7278487d610b] tool: 1 --- # OAuth-клиенты {#oauth-clients} @@ -82,18 +82,20 @@ translation: Когда `Client` отправляет первый запрос, сервер отвечает `401`. Дальше действует провайдер: -1. **Обнаружение.** Он читает заголовок `WWW-Authenticate`, загружает метаданные защищённого ресурса (Protected Resource Metadata) сервера с `/.well-known/oauth-protected-resource`, узнаёт, какой сервер авторизации защищает этот ресурс, и загружает метаданные уже *того* сервера. +1. **Обнаружение.** Он читает заголовок `WWW-Authenticate`, загружает метаданные защищённого ресурса (Protected Resource Metadata) сервера с `/.well-known/oauth-protected-resource`, узнаёт, какой сервер авторизации защищает этот ресурс, и загружает метаданные уже *того* сервера. (У сервера постарше, который не публикует метаданные ресурса, вместо этого запрашиваются метаданные сервера авторизации по его собственному origin.) В любом случае метаданные должны называть в поле `issuer` тот сервер, для которого они были загружены; всё остальное отклоняется. 2. **Регистрация.** В хранилище пусто? Он динамически регистрирует вас с вашим `OAuthClientMetadata` и сохраняет результат. 3. **Авторизация.** Он генерирует пару PKCE и `state`, собирает URL авторизации, ждёт ваш `redirect_handler`, а затем ждёт от `callback_handler` код. 4. **Обмен.** Он обменивает код на `OAuthToken`, сохраняет его и повторяет исходный запрос уже с `Authorization: Bearer ...`. После этого он работает незаметно. Токены берутся из хранилища, истёкший токен доступа обновляется с помощью токена обновления, и только когда ничего из этого не срабатывает, сценарий запускается заново. +Ко всем этим запросам применяется одно транспортное правило: как и MCP-запрос, внутри которого они выполняются, они следуют перенаправлению только тогда, когда оно остаётся на том же origin и сохраняет метод (скажем, 307/308 на завершающий слеш), а любое другое перенаправление считают тем, что этот URL не отвечает. + Ничего из этого вы не писали. Остаются два именованных аргумента (`client_metadata_url` и `validate_resource_url`), и этому файлу не нужен ни один из них. О `client_metadata_url` стоит знать — ему посвящён отдельный раздел ниже. ### Попробуйте сами {#try-it} -Большинство примеров в этой документации можно проверить с `Client(server)` в памяти. Этот — нет: весь смысл сценария в HTTP-ответе `401`, а между клиентом в памяти и его сервером никакого HTTP нет. +`Client(server)` в памяти, которым пользуются ваши тесты, здесь не поможет: весь смысл сценария в HTTP-ответе `401`, а между клиентом в памяти и его сервером никакого HTTP нет. В репозитории есть живая версия. `examples/servers/simple-auth/` запускает отдельный сервер авторизации и защищённый MCP-сервер; `examples/clients/simple-auth-client/` — это клиент с этой страницы, выросший в небольшой CLI. В его README — две команды: запустите серверы, запустите клиент, подключив его к ним, — и наблюдайте, как проходят все четыре шага. @@ -111,13 +113,14 @@ URL должен быть HTTPS и с некорневым путём; всё о `ClientCredentialsOAuthProvider` — тот же `httpx2.Auth`, только без человека: -```python title="client.py" hl_lines="4 27-33" +```python title="client.py" hl_lines="4 27-34" --8<-- "docs_src/oauth_clients/tutorial002.py" ``` Что изменилось: * Нет `OAuthClientMetadata`, нет обработчиков. Вы передаёте `client_id` и `client_secret`; провайдер строит вокруг них минимальную регистрацию `client_credentials` и полностью пропускает динамическую регистрацию. +* `issuer` называет сервер авторизации, который выдал эти учётные данные; используйте значение `issuer`, которое возвращает его документ `/.well-known/oauth-authorization-server`. Обнаружение по-прежнему проходит, как описано выше, но запросы токена строятся только по метаданным *этого* издателя; если MCP-сервер указывает куда-то ещё, сценарий останавливается с `OAuthFlowError`. Не указывать его — устаревший вариант, и в 3.0 параметр станет обязательным (см. **[Устаревшие возможности](../deprecated.md#deprecated-sdk-helpers)**); до тех пор провайдер выдаёт предупреждение и использует тот сервер авторизации, который найдёт обнаружение. * `scope` — строка с разделением пробелами, формат OAuth для передачи по сети. * Всё дальше по цепочке идентично: тот же `TokenStorage`, тот же `httpx2.AsyncClient(auth=...)`, тот же `streamable_http_client`. @@ -130,7 +133,7 @@ URL должен быть HTTPS и с некорневым путём; всё о Ещё один провайдер находится в `mcp.client.auth.extensions.client_credentials`: **`PrivateKeyJWTOAuthProvider`** — для клиентов, которые аутентифицируются с помощью JWT вместо общего секрета (`private_key_jwt`, вариант с парой ключей и workload identity). - Схема та же: создайте экземпляр и передайте его в `auth=`. В том же модуле есть + Схема та же: создайте экземпляр (он принимает тот же необязательный `issuer`) и передайте его в `auth=`. В том же модуле есть `SignedJWTParameters` и `static_assertion_provider` — два вспомогательных средства, которые собирают для него утверждение (assertion). diff --git a/i18n/ru/pages/client/session-groups.md b/i18n/ru/pages/client/session-groups.md index 7014c960..7b0fade0 100644 --- a/i18n/ru/pages/client/session-groups.md +++ b/i18n/ru/pages/client/session-groups.md @@ -1,6 +1,6 @@ --- translation: - sections: [09c857a25a9dc37a, 43bc6a76a243a50e, 0a716022a88768df, 4b7f78042bfcfff7, c112662e61b03315, 58974ba1f489a8b4, d18adbdbb835ea73] + sections: [09c857a25a9dc37a, 43bc6a76a243a50e, 0a716022a88768df, 4b7f78042bfcfff7, c112662e61b03315, 58974ba1f489a8b4, ed4d17e894864056] tool: 1 --- # Группы сессий {#session-groups} @@ -78,7 +78,7 @@ translation: ## Итоги {#recap} * `ClientSessionGroup` держит много подключений к серверам и сводит их инструменты, ресурсы и промпты в один `dict` для каждого вида. -* `connect_to_server(params)` — по одному на сервер. Принимает параметры транспорта, а не объект сервера или URL, как `Client`. +* `connect_to_server(params)` — по одному на сервер. Принимает параметры транспорта, а не URL или `Transport`, которые принимает `Client`. * `group.call_tool(name, arguments)` сам направляет вызов на сервер-владелец. * Имена должны быть уникальны в пределах всей группы; два сервера с инструментом `search` сами по себе ужиться не могут. * `component_name_hook=` переписывает каждое регистрируемое имя. Меняется ключ словаря, но не имя в передаваемых данных. diff --git a/i18n/ru/pages/client/transports.md b/i18n/ru/pages/client/transports.md index 1c24a01e..28a44383 100644 --- a/i18n/ru/pages/client/transports.md +++ b/i18n/ru/pages/client/transports.md @@ -1,6 +1,6 @@ --- translation: - sections: [9cac816674181eb0, 0700f337babcd4dd, 2bde0dd58cdf00f5, 40b4916d82eaf1d4, 3d0832f39b0d7059, dfa4446556badef0, 5bd93be2ab2ecb9c] + sections: [9cac816674181eb0, 7c157764133fea1f, 40b4916d82eaf1d4, 10d151f2cc75317f, 3d0832f39b0d7059, 92742ba36533633d, 0aeca6145e7bd302] tool: 1 --- # Клиентские транспорты {#client-transports} @@ -11,30 +11,15 @@ translation: *Серверная* сторона каждого из них (то, что делает `mcp.run()` и что вы развёртываете) описана на странице **[Запуск сервера](../run/index.md)**. -## В памяти {#in-memory} - -Передайте сам объект сервера: - -```python title="client.py" hl_lines="14" ---8<-- "docs_src/client_transports/tutorial001.py" -``` - -Ни подпроцесса, ни порта, ни байтов в передаваемых данных. Клиент и сервер — два объекта в одном процессе, и вызов всё равно проходит через настоящий протокольный уровень: `search_books` перечисляется, валидируется и вызывается ровно так же, как это было бы по HTTP. - -Поэтому это сразу две вещи: - -* **Тестовый стенд.** Каждый пример в этой документации проверяется именно так, а страница **[Тестирование](../get-started/testing.md)** строит вокруг этого весь подход. -* **API для встраивания.** Приложению, которое создаёт сервер, не нужен сетевой переход, чтобы вызывать его инструменты. - ## Streamable HTTP {#streamable-http} -Передайте строку с URL — и получите **Streamable HTTP**, транспорт, за которым вы развёртываете сервер: +Передайте строку с URL — и получите **Streamable HTTP**, транспорт, за которым вы развёртываете сервер и с которого стоит начинать: ```python title="client.py" hl_lines="5" --8<-- "docs_src/client_transports/tutorial002.py" ``` -Это уже готовый клиент для продакшена. `Client` сам оборачивает URL в `streamable_http_client(...)` поверх `httpx2.AsyncClient`, настроенного так, как нужно MCP: `follow_redirects=True`, таймаут 30 секунд на connect/write/pool и таймаут чтения 300 секунд, потому что сервер может держать поток ответа открытым. +Это уже готовый клиент для продакшена. `Client` сам оборачивает URL в `streamable_http_client(...)` поверх `httpx2.AsyncClient`, настроенного так, как нужно MCP: таймаут 30 секунд на connect/write/pool и таймаут чтения 300 секунд, потому что сервер может держать поток ответа открытым. !!! check Созданный `Client` **не** подключён. Конструктор только выбирает транспорт; @@ -50,7 +35,7 @@ translation: Как только понадобится заголовок `Authorization`, cookie, прокси, mTLS или другой таймаут, создайте `httpx2.AsyncClient` сами и передайте его в `streamable_http_client`: -```python title="client.py" hl_lines="8-14" +```python title="client.py" hl_lines="8-13" --8<-- "docs_src/client_transports/tutorial003.py" ``` @@ -80,9 +65,30 @@ translation: !!! info `httpx2` сохраняет привычный API `httpx`, так что, если вы знаете `httpx`, вы уже умеете делать здесь аутентификацию, прокси, хуки событий, повторные попытки и ограничения соединений. SDK ничего не добавляет сверху - и ничего не убирает. Здесь же подключается OAuth: + и ничего не убирает, кроме [обработки редиректов](#redirects). Здесь же подключается OAuth: `httpx2.AsyncClient(auth=OAuthClientProvider(...))`. Весь этот сценарий — на странице **[OAuth-клиенты](oauth-clients.md)**. +### Редиректы {#redirects} + +Транспорт подключается к URL, который вы ему передали, и ни к какому другому источнику (origin). + +* Редирект `307`/`308`, который остаётся на той же схеме, хосте и порту, выполняется; то же касается перехода `http://` → `https://` на том же хосте. Это покрывает обычный редирект с добавлением косой черты `/mcp` → `/mcp/`. +* Редирект куда-либо ещё **не** выполняется. Вызов завершается ошибкой: + + ```text + MCPError: Redirect to https://other.example.com/mcp not followed; use that URL as the endpoint if it is the intended server + ``` + + Если этот URL и есть нужный сервер, укажите его в конфигурации. Если нет — сервер или прокси перед ним настроен неправильно. + +Это верно для любого `httpx2.AsyncClient`, который вы передаёте: его настройка `follow_redirects` для MCP-запросов не учитывается — ни в одну, ни в другую сторону. OAuth-провайдеры SDK применяют то же правило к своим собственным запросам. + +!!! tip + Сообщение `Redirect to http://… not followed: it would downgrade this HTTPS endpoint to plain HTTP` означает, что + сервер стоит за прокси с терминацией TLS, о котором не знает, и выдаёт редиректы на `http://`. + Это исправляется на сервере (**[Развёртывание и масштабирование](../run/deploy.md#behind-a-tls-terminating-proxy)**) + либо использованием ровно того URL `https://…/`, который предлагает сообщение. + ## stdio {#stdio} Сервер **stdio** — это подпроцесс. Клиент запускает его, пишет JSON-RPC в его stdin и читает JSON-RPC из его stdout. Именно так десктопный хост запускает сервер на вашей машине: хост — это *и есть* этот код плюс UI, а страница **[Подключение к реальному хосту](../get-started/real-host.md)** показывает те же отношения со стороны хоста, в виде файла конфигурации. @@ -105,6 +111,18 @@ stderr дочернего процесса идёт в ваш. Чтобы нап Сервер, которому нужен API-ключ, там его не найдёт. Передайте его явно через `env=`; эти переменные добавляются поверх разрешённого списка. Именно это делает `BOOKSHOP_API_KEY` выше. +## В памяти {#in-memory} + +В тесте нечего развёртывать и нечего запускать. Передайте сам объект сервера: + +```python hl_lines="14" +--8<-- "docs_src/client_transports/tutorial001.py" +``` + +Ни подпроцесса, ни порта, ни байтов в сети. Клиент и сервер — два объекта в одном процессе, и вызов всё равно проходит через настоящий протокольный уровень: `search_books` перечисляется, валидируется и вызывается ровно так же, как это было бы по HTTP. Страница **[Тестирование](../get-started/testing.md)** строит вокруг этого весь подход. + +Та же форма служит и API для встраивания: приложение, которое само создаёт сервер, может вызывать его инструменты без обращения к сети. + ## SSE {#sse} `sse_client(url)` из `mcp.client.sse` — это HTTP-транспорт, который заменил Streamable HTTP. Оборачивайте его так же, `Client(sse_client("http://localhost:8000/sse"))`, чтобы общаться с сервером, который всё ещё на нём говорит, и не стройте на нём ничего нового. @@ -113,15 +131,16 @@ stderr дочернего процесса идёт в ваш. Чтобы нап Для `Client` всё перечисленное — одно и то же. -**Транспорт** — это любой асинхронный контекстный менеджер, который отдаёт пару потоков сообщений `(read, write)`: формально — протокол `Transport` из `mcp.client`. `Client` разрешает свой аргумент по типу: объект сервера подключается внутри процесса, `str` превращается в `streamable_http_client(url)`, `StdioServerParameters` — в `stdio_client(params)`, а всё остальное используется как транспорт напрямую. Благодаря последнему правилу `stdio_client(...)`, `streamable_http_client(...)` и `sse_client(...)` подходят в одно и то же место — и вы можете написать свой. +**Транспорт** — это любой асинхронный контекстный менеджер, который отдаёт пару потоков сообщений `(read, write)`: формально — протокол `Transport` из `mcp.client`. `Client` разрешает свой аргумент по типу: `str` превращается в `streamable_http_client(url)`, `StdioServerParameters` — в `stdio_client(params)`, объект сервера подключается внутри процесса, а всё остальное открывается напрямую как транспорт. Благодаря последнему правилу `stdio_client(...)`, `streamable_http_client(...)` и `sse_client(...)` встают в одно и то же место — и поэтому же можно написать свой собственный. ## Итоги {#recap} -* `Client(mcp)` (объект сервера) подключается в памяти. Используйте для тестов и для встраивания. * `Client("http://.../mcp")` (URL) подключается по Streamable HTTP, транспорту для продакшена. * Заголовки, аутентификация, прокси и таймауты задаются на `httpx2.AsyncClient`, который передаётся в `streamable_http_client(url, http_client=...)`. Именованного аргумента `headers=` нет. +* Редиректы выполняются только в пределах источника самого URL (`307`/`308` с добавлением косой черты) плюс `http`→`https` на том же хосте. Всё остальное завершается ошибкой `Redirect to … not followed`; укажите в конфигурации конечный URL. * stdio — это `Client(StdioServerParameters(...))`. Оборачивайте его в `stdio_client(...)` сами, только чтобы перенаправить stderr дочернего процесса. * Подпроцесс получает окружение из разрешённого списка, а не ваше; `env=` добавляет к нему. +* `Client(mcp)` (объект сервера) подключается в памяти. Используйте его в тестах или чтобы встроить сервер в приложение, которое его создало. * Транспорт — это всё, с чем можно написать `async with x as (read, write)`. `Client` передаёт всё, что не объект сервера, не URL и не `StdioServerParameters`, прямо в этот протокол. * Создание `Client` выбирает транспорт. `async with` его открывает. diff --git a/i18n/ru/pages/deprecated.md b/i18n/ru/pages/deprecated.md index ba399dd3..b014bc3f 100644 --- a/i18n/ru/pages/deprecated.md +++ b/i18n/ru/pages/deprecated.md @@ -1,11 +1,11 @@ --- translation: - sections: [490237e61c3a7a44, 01262a123ad9501d, 429db5b574a2ac08, e2d0d273fbd2d74b, 64ab0331e868f3d4, 6c8878ce2d1f6d56, 4068f23e371bf0b3, eaef75b8725bc931] + sections: [4c1dea378b2b1bf7, 01262a123ad9501d, 429db5b574a2ac08, e2d0d273fbd2d74b, 64ab0331e868f3d4, 6c8878ce2d1f6d56, c3d1099701156881, e185a1b8e53669f6] tool: 1 --- # Устаревшие возможности {#deprecated-features} -Спецификация 2026-07-28 выводит из обращения пять возможностей. SDK по-прежнему реализует каждую из них, и каждая теперь выдаёт **предупреждение об устаревании**. Один вспомогательный метод SDK объявлен устаревшим по собственным причинам и описан [в конце страницы](#deprecated-sdk-helpers). +Спецификация 2026-07-28 выводит из обращения пять возможностей. SDK по-прежнему реализует каждую из них, и каждая теперь выдаёт **предупреждение об устаревании**. Несколько устареваний на уровне самого SDK стоят особняком и перечислены [в конце страницы](#deprecated-sdk-helpers). В таблице ниже перечислены все устаревшие возможности, причина, по которой каждая уходит, и замена, на которую стоит опираться. @@ -137,13 +137,15 @@ warnings.filterwarnings("ignore", category=MCPDeprecationWarning) Одна строка в конфигурации pytest — и устаревший вызов уже не сможет незаметно вернуться в кодовую базу, не провалив тест. -## Устаревшие вспомогательные методы SDK {#deprecated-sdk-helpers} +## Устаревшие вспомогательные средства SDK {#deprecated-sdk-helpers} -Это не изменения спецификации, а лишь внутренние детали SDK, у которых появилась лучшая замена. Они выдают то же предупреждение `MCPDeprecationWarning` и будут удалены в версии 3.0. +Это не изменения спецификации, а лишь способы использования SDK, у которых появилась лучшая замена. Они выдают то же предупреждение `MCPDeprecationWarning`, а в версии 3.0 прежняя форма будет удалена. | Устарело | Что делать вместо этого | |---|---| | `FuncMetadata.call_fn_with_arg_validation()` | `FuncMetadata.validate_arguments()`, а затем `FuncMetadata.call_fn()`. Его вызывал только код, работающий с `FuncMetadata` напрямую (скажем, собственный подкласс `Tool`). | +| `AuthSettings(resource_server_url=...)` без `validate_token_resource=` | Задайте его: при `True` сервер отклоняет bearer-токены, которые ваш верификатор не признаёт выпущенными для `resource_server_url`; `False` означает, что верификатор сам проверяет аудиторию токена (см. **[Авторизация](run/authorization.md#a-token-verifier)**). Незаданное значение ведёт себя как `False`; в версии 3.0 `True` станет значением по умолчанию всякий раз, когда задан `resource_server_url`. | +| `ClientCredentialsOAuthProvider(...)` или `PrivateKeyJWTOAuthProvider(...)` без `issuer=` | Передайте `issuer=` с указанием сервера авторизации, который выдал учётные данные (см. **[Написание OAuth-клиентов](client/oauth-clients.md#machine-to-machine)**). Без него MCP-сервер сам решает, какой сервер авторизации их получит; в версии 3.0 этот именованный аргумент станет обязательным. | ## Итоги {#recap} @@ -152,7 +154,7 @@ warnings.filterwarnings("ignore", category=MCPDeprecationWarning) * Устаревание носит рекомендательный характер: в передаваемых данных ничего не меняется, всё продолжает работать в сессиях до 2026 года, и появляется заметное предупреждение `MCPDeprecationWarning` (это `UserWarning`, поэтому оно включено по умолчанию). * Сэмплированию и корневым каталогам дополнительно нужен обратный канал, которого в сессии 2026-07-28 нет. На современном подключении они выдают предупреждение, а затем выбрасывают исключение. * `warnings.filterwarnings("ignore", category=MCPDeprecationWarning)` заглушает всю категорию; `"error::mcp.MCPDeprecationWarning"` в pytest превращает её в провал теста. -* Один вспомогательный метод SDK, `FuncMetadata.call_fn_with_arg_validation()`, объявлен устаревшим отдельно и будет удалён в версии 3.0. +* [Устаревания на уровне SDK](#deprecated-sdk-helpers) подчиняются тому же правилу: сейчас они предупреждают, а в версии 3.0 прежняя форма будет удалена. * Новый код не следует строить ни на одной из этих возможностей. Все остальные страницы этой документации описывают актуальный API. diff --git a/i18n/ru/pages/get-started/first-steps.md b/i18n/ru/pages/get-started/first-steps.md index 0a2d2790..f1ef017b 100644 --- a/i18n/ru/pages/get-started/first-steps.md +++ b/i18n/ru/pages/get-started/first-steps.md @@ -1,6 +1,6 @@ --- translation: - sections: [0d6c05bcbf836bf3, 59a7b14eeefc68c1, 7114d8d6daba203f, e8bbb56a98ba7bc9, 5138010f6159901c, f78da7c7c363d4c6, 220a939cab348686] + sections: [0d6c05bcbf836bf3, 9a78b5f6b44b18ab, 7114d8d6daba203f, e8bbb56a98ba7bc9, bfd2fd1153e71dac, 1615a994ef071fdd, 65c599fae991f245] tool: 1 --- # Первые шаги {#first-steps} @@ -17,7 +17,7 @@ translation: * **Клиент** живёт внутри хоста и говорит на MCP. Хост запускает по одному клиенту на каждый сервер, к которому подключён. * **Сервер** — это то, что вы строите с помощью этого SDK. Он предоставляет клиентам разные вещи. С моделью напрямую он никогда не общается. -Вы пишете сервер. Хосты — это чужой продукт. SDK также даёт класс `Client`. Он пригодится для тестирования серверов и появится ниже на этой странице. +Вы пишете сервер. Хосты — это чужой продукт. SDK также даёт класс `Client` — тот же самый, которым хост подключился бы к серверу по URL или запустил бы его как подпроцесс. Он появится ниже на этой странице, и им же вы будете тестировать свои серверы. ## Три примитива {#the-three-primitives} @@ -83,22 +83,20 @@ Inspector запустил ваш сервер через **stdio** — один Когда клиент подключается, сервер объявляет свои **возможности**: на какие семейства запросов он будет отвечать. По этому объявлению клиент решает, о чём вообще имеет смысл спрашивать. Вы его не писали; `MCPServer` объявляет его за вас. -Посмотрите сами. Класс `Client` из SDK принимает объект сервера напрямую и подключается к нему **в памяти** (без подпроцесса, без порта): +Посмотрите сами. Оставьте `server.py` работать по HTTP в одном терминале: -```python -import asyncio +```console +uv run mcp run server.py --transport streamable-http +``` -from mcp import Client +а из другого направьте на него клиент: -from server import mcp +```python title="client.py" hl_lines="7-8" +--8<-- "docs_src/first_steps/tutorial001_client.py" +``` - -async def main() -> None: - async with Client(mcp) as client: - print(client.server_capabilities.model_dump(exclude_none=True)) - - -asyncio.run(main()) +```console +python client.py ``` ```text @@ -118,9 +116,9 @@ asyncio.run(main()) Обратите внимание на то, чего здесь нет. `completions` (автодополнение аргументов для шаблонов ресурсов и промптов) требует обработчика, который пишете вы; у этого сервера его нет, поэтому возможность отсутствует, и корректный клиент о ней не спросит. Таково правило для всего необязательного: зарегистрируйте нужное — и возможность появится; страница **[Автодополнение](../servers/completions.md)** это демонстрирует. !!! info - `Client(mcp)` — тот самый клиент в памяти, которым тестируется каждый пример в этой - документации, и именно так вы будете тестировать свои. Ему отведена целая страница: - **[Тестирование](testing.md)**. + Этот `client.py` — полноценный MCP-клиент, и ему посвящена страница **[Клиент](../client/index.md)**. + В тесте терминал и порт не нужны: передайте `Client` сам объект сервера — + `Client(mcp)`. Этому тоже отведена целая страница: **[Тестирование](testing.md)**. ## Чего вы не писали {#what-you-did-not-write} @@ -129,7 +127,7 @@ asyncio.run(main()) * JSON Schema. `a: int, b: int` — это *и есть* схема для `add`. * Обработчик запросов. `tools/list`, `resources/read`, `prompts/get` — всё это обслуживается за вас. * Объявление возможностей. `MCPServer` составил его за вас. -* Ни строчки протокола. Согласование версии, обрамление JSON-RPC, обмен возможностями — всё это произошло внутри `mcp dev` и `Client(mcp)`, и вы этого не видели. +* Ни строчки протокола. Согласование версии, обрамление JSON-RPC, обмен возможностями — всё это произошло внутри `mcp dev` и `client.py`, и вы этого не видели. В этом соотношении — весь смысл SDK. @@ -140,6 +138,6 @@ asyncio.run(main()) * По одному декоратору на примитив: `@mcp.tool()`, `@mcp.resource(uri)`, `@mcp.prompt()`. Имя, описание и схема берутся из функции. * URI с `{param}` создаёт **шаблон** ресурса, который отображается отдельно от конкретных ресурсов. * **Возможности** сервера объявляются за вас, а клиент спрашивает только о том, что сервер объявил. -* `Client(mcp)` подключается к объекту сервера в памяти: ваш тестовый стенд с первого дня. +* `Client("http://localhost:8000/mcp")` разговаривает с запущенным сервером. Передайте ему вместо этого сам объект сервера, `Client(mcp)`, — и это ваш тестовый стенд с первого дня. Дальше — **[Подключение к настоящему хосту](real-host.md)**: этот же сервер внутри Claude Desktop или IDE, по-настоящему. Затем **[Тестирование](testing.md)**: одна страница, один клиент в памяти — и больше не придётся гадать, работает ли оно. После этого каждому примитиву отведена своя страница, начиная с того, которым управляет модель: **[Инструменты](../servers/tools.md)**. diff --git a/i18n/ru/pages/get-started/testing.md b/i18n/ru/pages/get-started/testing.md index 1f4d623e..24a0a0ad 100644 --- a/i18n/ru/pages/get-started/testing.md +++ b/i18n/ru/pages/get-started/testing.md @@ -1,13 +1,13 @@ --- translation: - sections: ['4926721070127497', c52a1de2b6b32f40, 8e792bf8c7489ec6, 627195f7159e24ef] + sections: [5d13c2f0ba42c0d2, c52a1de2b6b32f40, 8e792bf8c7489ec6, 38552ea228b0a04f] tool: 1 --- # Тестирование {#testing} -В Python SDK есть класс `Client` со **встроенным in-memory транспортом**: передайте ему объект сервера, и он подключится к нему напрямую. +Класс `Client` из SDK — тот самый, что подключается к URL или запускает подпроцесс, — умеет подключаться и **в памяти**: передайте ему объект сервера, и он будет говорить с ним напрямую. -Ни подпроцесса. Ни порта. Вообще никакого транспорта. Та же идея, что и `TestClient` в FastAPI. +Ни подпроцесса. Ни порта. Ничего не передаётся по сети. Та же идея, что и `TestClient` в FastAPI. ## Базовое использование {#basic-usage} @@ -97,7 +97,7 @@ async def test_call_add_tool(client: Client): В тестах оставляйте его включённым. В продакшен-коде он не имеет смысла. -## Внутри процесса по умолчанию {#in-process-by-default} +## Без привязки к поколению протокола по умолчанию {#era-neutral-by-default} !!! note `Client(mcp)` подключается внутри процесса и по умолчанию **не привязан к поколению diff --git a/i18n/ru/pages/handlers/multi-round-trip.md b/i18n/ru/pages/handlers/multi-round-trip.md index 2c0ba395..172e2c33 100644 --- a/i18n/ru/pages/handlers/multi-round-trip.md +++ b/i18n/ru/pages/handlers/multi-round-trip.md @@ -1,6 +1,6 @@ --- translation: - sections: [74011e683045eea9, 9b64cc175c18b6a9, 4b41be4824030397, e3b1502da786ec33, 71e41161f143c6a9, 9ec2c1eeb8c36378, 8dd027377d46448b, f81491125dcbfe8b] + sections: [74011e683045eea9, 9b64cc175c18b6a9, 4b41be4824030397, e3b1502da786ec33, 71e41161f143c6a9, 9ec2c1eeb8c36378, b47667184ca5b516, f81491125dcbfe8b] tool: 1 --- # Многораундовые запросы (multi-round-trip) {#multi-round-trip-requests} @@ -164,7 +164,7 @@ TTL, привязка к принципалу и привязка к запро ## Результат версии 2026-07-28 {#a-2026-07-28-result} -`InputRequiredResult` существует только в версии протокола **2026-07-28**. `Client(server)` в памяти согласует её за вас; по сети её обнаруживает `mode="auto"`. После подключения `client.protocol_version` сообщает, что именно получилось. +`InputRequiredResult` существует только в версии протокола **2026-07-28**. Режим `mode="auto"`, который `Client` использует по умолчанию, обнаруживает её на любом подключении. После подключения `client.protocol_version` сообщает, что именно получилось. !!! warning В сессии до 2026 года `InputRequiredResult` некуда положить. Верните его из обработчика на diff --git a/i18n/ru/pages/handlers/progress.md b/i18n/ru/pages/handlers/progress.md index 77fcb71f..4bea42be 100644 --- a/i18n/ru/pages/handlers/progress.md +++ b/i18n/ru/pages/handlers/progress.md @@ -1,6 +1,6 @@ --- translation: - sections: [5315262fe26b33e1, 9d8e98840f1b78f0, 0284b215e85366c4, 8534d8dbb4053a70, 2966fac6fe697007] + sections: [5315262fe26b33e1, 9d8e98840f1b78f0, 52d6009a07e770ea, 8534d8dbb4053a70, 2966fac6fe697007] tool: 1 --- # Ход выполнения {#progress} @@ -29,19 +29,17 @@ translation: Клиент подписывается **на каждый вызов отдельно**, передавая `progress_callback=` в `call_tool`: -```python title="client.py" hl_lines="7 16" +```python title="client.py" hl_lines="5 14" import anyio from mcp import Client -from server import mcp - async def show(progress: float, total: float | None, message: str | None) -> None: print(f"{message} ({progress}/{total})") async def main() -> None: - async with Client(mcp) as client: + async with Client("http://localhost:8000/mcp") as client: result = await client.call_tool( "import_catalog", {"urls": ["https://example.com/a.json", "https://example.com/b.json"]}, @@ -56,29 +54,32 @@ anyio.run(main) Колбэк — это `async`-функция, принимающая ровно то, что сообщил сервер: `progress`, `total`, `message`. !!! info - `Client(mcp)` подключается напрямую к объекту сервера, в памяти, — это тот же клиент, на котором - построена страница **[Тестирование](../get-started/testing.md)**. Параметр `progress_callback` один - и тот же, какой бы транспорт ни использовал `Client`; а вот *временны́е характеристики*, которые вы - сейчас увидите, относятся к подключению в памяти. Оно выполняет колбэк прямо на месте, поэтому - каждый отчёт приходит до того, как `call_tool` вернёт управление. По настоящему транспорту - уведомления соревнуются с результатом, и медленный колбэк может всё ещё работать после того, как - `call_tool` уже вернул управление. + Параметр `progress_callback` один и тот же, что бы вы ни передали в `Client`: URL, как здесь, + `StdioServerParameters` или объект сервера в тесте. Но по настоящему транспорту учитывайте + временны́е характеристики. Каждое уведомление доставляется само по себе, отдельно от ответа, поэтому + медленный колбэк может всё ещё работать после того, как `call_tool` уже вернул управление. Только + внутрипроцессное тестовое подключение выполняет колбэк прямо на месте и гарантирует, что каждый + отчёт придёт раньше результата. ### Попробуйте сами {#try-it} -Положите `client.py` рядом с `server.py` и запустите: +Запустите `server.py` по HTTP, затем из второго терминала запустите клиент: + +```console +uv run mcp run server.py --transport streamable-http +``` ```console python client.py ``` ```text -Imported https://example.com/a.json (1/2) -Imported https://example.com/b.json (2/2) +Imported https://example.com/a.json (1.0/2.0) +Imported https://example.com/b.json (2.0/2.0) {'result': 'Imported 2 records.'} ``` -Каждый `await ctx.report_progress(...)` на сервере превратился в один вызов `show` на клиенте, в том же порядке, и обе строки напечатались **до** того, как `call_tool` вернул управление. Ход выполнения не упаковывается в результат: он передаётся потоком, пока инструмент ещё работает. +Каждый `await ctx.report_progress(...)` на сервере превратился в один вызов `show` на клиенте, в том же порядке. Ход выполнения не упаковывается в результат. Он передаётся потоком, пока инструмент ещё работает. !!! warning `progress_callback` относится к **вызову**, а не к `Client`. Аргумента конструктора для него нет, diff --git a/i18n/ru/pages/protocol-versions.md b/i18n/ru/pages/protocol-versions.md index 195d3026..719373e0 100644 --- a/i18n/ru/pages/protocol-versions.md +++ b/i18n/ru/pages/protocol-versions.md @@ -1,6 +1,6 @@ --- translation: - sections: [478fd619e5f90ef8, aef094a00e44e248, bab8cbf3449fa7e9, df1809b15a58335b, 5f9d8c2336ed0239, f54974398e43ddef, b24443dd78584870] + sections: [d98e337bf28845e0, dd642acbba36f615, f547b3e76da19c94, 149513378588da5c, 4e149ed992046709, f54974398e43ddef, b24443dd78584870] tool: 1 --- # Версии протокола {#protocol-versions} @@ -11,9 +11,17 @@ translation: Заботиться об этом почти никогда не приходится: `Client` договаривается за вас. Эта страница — об одном аргументе конструктора, который этим управляет, `mode=`, и о трёх случаях, когда его меняют. +Каждый фрагмент на этой странице — это `client.py`, который общается с сервером Bookshop `server.py` со страницы **[Клиент](client/index.md)**. Запустите этот сервер в одном терминале: + +```console +uv run mcp run server.py --transport streamable-http +``` + +Затем запускайте каждый фрагмент во втором терминале командой `python client.py`. + ## `mode="auto"` {#modeauto} -```python title="client.py" hl_lines="14-15" +```python title="client.py" hl_lines="7-8" --8<-- "docs_src/protocol_versions/tutorial001.py" ``` @@ -31,13 +39,14 @@ translation: Вот и вся механика. Один `Client`, сервер любого поколения, никаких ветвлений в коде. !!! info - `MCPServer` отвечает на `server/discover` на любом транспорте — в памяти, stdio, Streamable - HTTP, — поэтому с собственным сервером `auto` всегда приходит к `2026-07-28`. Откат - срабатывает только с настоящим сервером до 2026 года — ровно тогда, когда он и нужен. + `MCPServer` отвечает на `server/discover` на любом транспорте — Streamable HTTP, stdio и + внутрипроцессном подключении, которое используют ваши тесты, — поэтому с собственным сервером + `auto` всегда приходит к `2026-07-28`. Откат срабатывает только с настоящим сервером до + 2026 года — ровно тогда, когда он и нужен. ## `mode="legacy"` {#modelegacy} -```python title="client.py" hl_lines="14" +```python title="client.py" hl_lines="7" --8<-- "docs_src/protocol_versions/tutorial002.py" ``` @@ -61,7 +70,7 @@ translation: `mode` принимает и строку современной версии протокола. Сегодня это множество ровно `["2026-07-28"]`. -```python title="client.py" hl_lines="14" +```python title="client.py" hl_lines="7" --8<-- "docs_src/protocol_versions/tutorial003.py" ``` @@ -94,7 +103,7 @@ ValueError: mode must be 'legacy', 'auto', or one of ['2026-07-28']; got '2025-0 Так что сохраните его. После подключения в режиме `auto` в `client.session.discover_result` лежит ровно тот `DiscoverResult`, который прислал сервер: его `supported_versions`, `capabilities`, `instructions` и идентификационные данные, которые сервер записал в `_meta` результата. В следующий раз передайте его обратно как `prior_discover=`: -```python title="client.py" hl_lines="15 17" +```python title="client.py" hl_lines="8 10" --8<-- "docs_src/protocol_versions/tutorial004.py" ``` diff --git a/i18n/ru/pages/run/asgi.md b/i18n/ru/pages/run/asgi.md index 8fd397b4..bacb4f75 100644 --- a/i18n/ru/pages/run/asgi.md +++ b/i18n/ru/pages/run/asgi.md @@ -1,6 +1,6 @@ --- translation: - sections: [1062ef792791488a, 4be2b831547184a9, 374b049e770385f2, b72f6947089e6de0, b172c9db7831bb31, 70b9ece244ca1b0c, cba78e052898c3f6, f06bdb541cb0b469, fb82d526320b7cc3] + sections: [1062ef792791488a, 4be2b831547184a9, 374b049e770385f2, b72f6947089e6de0, b172c9db7831bb31, 10394c6f16601638, cba78e052898c3f6, f06bdb541cb0b469, 6dc898ccc5a903f9] tool: 1 --- # Добавление в существующее приложение {#add-to-an-existing-app} @@ -102,7 +102,7 @@ uvicorn server:app --8<-- "docs_src/asgi/tutorial004.py" ``` -Теперь клиенты подключаются к `/notes`, а не к `/notes/mcp`. +Теперь клиенты подключаются к `/notes/`, а не к `/notes/mcp`. ## CORS для браузерных клиентов {#cors-for-browser-clients} @@ -137,7 +137,7 @@ uvicorn server:app ## Итоги {#recap} * `mcp.streamable_http_app()` возвращает приложение Starlette с одним маршрутом, `/mcp`. Запустить его может любой ASGI-сервер. -* По умолчанию приложение отвечает только на запросы, адресованные localhost, а за настоящим именем хоста отклоняет всё кодом `421`, пока вы не передадите список разрешённых хостов в `transport_security=`. За это и за остальной путь к продакшену отвечает страница **[Развёртывание и масштабирование](deploy.md)**. +* По умолчанию приложение отвечает только на запросы, адресованные localhost, а за настоящим именем хоста отклоняет всё кодом `421`, пока вы не передадите в `transport_security=` список разрешённых хостов. За это и за остальной путь к продакшену отвечает страница **[Развёртывание и масштабирование](deploy.md)**. * `Mount` (или `Host`) помещает его внутрь более крупного приложения Starlette или FastAPI. * **Монтирование отключает встроенный жизненный цикл.** Жизненный цикл хост-приложения должен войти в `mcp.session_manager.run()`, иначе первый запрос завершится ошибкой. * Несколько серверов в одном приложении — это несколько монтирований и один жизненный цикл, который входит в каждый менеджер сессий. @@ -145,4 +145,4 @@ uvicorn server:app * Браузерным клиентам нужен CORS: `allow_headers` для заголовков запроса `Mcp-*`, `expose_headers=["Mcp-Session-Id"]` для ответа. * `@mcp.custom_route()` добавляет обычные HTTP-точки без аутентификации рядом с `/mcp`. -Когда сервер доступен по настоящему URL, **[Клиент](../client/index.md)** подключается к нему по этому URL вместо объекта сервера. +Когда сервер доступен по настоящему URL, **[Клиент](../client/index.md)** подключается к нему по этому URL. diff --git a/i18n/ru/pages/run/authorization.md b/i18n/ru/pages/run/authorization.md index c4059c1a..68dd7b77 100644 --- a/i18n/ru/pages/run/authorization.md +++ b/i18n/ru/pages/run/authorization.md @@ -1,19 +1,19 @@ --- translation: - sections: [d62c13457fc4a534, 80e73abaca6e0652, d1dc4c54cd00ec9c, 14ad3bc7904036bb, 5225f127bc1b9c77, fe1626fdd5aad1da, 4556cb7ea1a04a31] + sections: [d62c13457fc4a534, 80e73abaca6e0652, 128a492d18295f64, 14ad3bc7904036bb, 54a6697833fcc1d9, fe1626fdd5aad1da, 811d083c1da8bcf6] tool: 1 --- # Авторизация {#authorization} -Через Streamable HTTP MCP-сервер — это обычный веб-сервис, и защищается он так же, как любой веб-сервис: с помощью bearer-токенов OAuth 2.1. +При работе через Streamable HTTP ваш MCP-сервер — обычный веб-сервис, и защищают его так же, как любой веб-сервис: bearer-токенами OAuth 2.1. -В терминах OAuth ваш сервер — это **сервер ресурсов**. Он никого не аутентифицирует и не выдаёт токенов. Он делает ровно одно: смотрит на заголовок `Authorization` каждого запроса и решает, годится ли токен в нём. +В терминах OAuth ваш сервер — это **сервер ресурсов**. Он никогда не выполняет вход пользователей и никогда не выдаёт токены. Он делает одно: смотрит на заголовок `Authorization` в каждом запросе и решает, годится ли токен в нём. -Эта страница — о серверной стороне. Клиент, который обнаруживает ваш сервер авторизации и получает токен, описан на странице **[OAuth-клиенты](../client/oauth-clients.md)**. +Эта страница — о серверной стороне. Клиент, который находит ваш сервер авторизации и получает токен, описан на странице **[OAuth-клиенты](../client/oauth-clients.md)**. ## Три стороны {#the-three-parties} -* **Сервер авторизации** аутентифицирует пользователей и выдаёт токены доступа. Его вы не пишете. Это ваш провайдер идентификации (Auth0, Keycloak, Entra или ваш собственный). +* **Сервер авторизации** выполняет вход пользователей и выдаёт токены доступа. Его вы не пишете. Это ваш провайдер идентификации (Auth0, Keycloak, Entra или собственный). * **Сервер ресурсов** — это ваш MCP-сервер. Он проверяет токен в каждом запросе. * **Клиент** выясняет, какому серверу авторизации вы доверяете, получает у него токен и присылает его вам в виде `Authorization: Bearer `. @@ -21,29 +21,33 @@ translation: ## Верификатор токенов {#a-token-verifier} -SDK ничего не предполагает о том, как выглядит действительный токен. Это определяете вы, реализуя **`TokenVerifier`**: +У SDK нет мнения о том, как выглядит действительный токен. Это сообщаете вы, реализуя **`TokenVerifier`**: -```python title="server.py" hl_lines="12-14 19-24" +```python title="server.py" hl_lines="14-16 21-27" --8<-- "docs_src/authorization/tutorial001.py" ``` -* `TokenVerifier` — это протокол с одним асинхронным методом. `verify_token` получает сырой токен из заголовка `Authorization` и возвращает **`AccessToken`**, если токен действителен, или `None`, если нет. Больше реализовывать нечего. -* Этот верификатор ищет токен в таблице. Настоящий проверяет подпись JWT или обращается к эндпоинту интроспекции токенов на сервере авторизации. Этот код — ваш; SDK его только вызывает. +* `TokenVerifier` — протокол с одним асинхронным методом. `verify_token` получает сырой токен из заголовка `Authorization` и возвращает **`AccessToken`**, если токен действителен, и `None`, если нет. Больше реализовывать нечего. +* Этот верификатор ищет токен в таблице; в каждой записи указан ресурс, для которого токен выдан. Настоящий проверяет подпись JWT или вызывает эндпоинт интроспекции токенов сервера авторизации и сообщает, для кого выдан токен (его `aud`), в `AccessToken.resource`. Этот код пишете вы; SDK его только вызывает. * `token_verifier=` и `auth=` всегда идут в паре. Передайте один без другого — и `MCPServer(...)` выбросит `ValueError` ещё до того, как обслужит хоть один запрос. -`AuthSettings` — это публичное лицо вашего сервера ресурсов: +`AuthSettings` — публичное лицо вашего сервера ресурсов: * `issuer_url`: сервер авторизации, который выдаёт ваши токены. -* `resource_server_url`: публичный URL этого MCP-эндпоинта. Он указывает, *для какого* ресурса предназначен токен, и по нему же размещается документ обнаружения. +* `resource_server_url`: публичный URL этого MCP-эндпоинта. Он указывает, для *какого* ресурса предназначен токен, и по нему же располагается документ обнаружения. * `required_scopes`: каждый токен должен содержать их все. +* `validate_token_resource`: отклонять любой токен, у которого `AccessToken.resource` не равен `resource_server_url`. Если оставить его незаданным при заданном `resource_server_url`, выдаётся предупреждение (`MCPDeprecationWarning`), а поведение такое же, как при `False`; в версии 3.0 значением по умолчанию для серверов ресурсов станет `True`. + * Включите его, если ваш сервер авторизации привязывает токены к параметру `resource`, который запросил клиент, — MCP-клиенты передают его всегда. Следите, чтобы `resource_server_url` в точности совпадал с URL, к которому подключаются клиенты. + * Оставьте выключенным, если сервер авторизации использует собственные идентификаторы аудитории (идентификатор API в Auth0, идентификатор приложения в Entra), и вместо этого проверяйте `aud` в верификаторе, возвращая `None` для токена, предназначенного не этому серверу. + * Если `aud` — список, поместите в `resource` тот элемент, который равен `resource_server_url`. !!! tip - В `examples/servers/simple-auth/` в репозитории SDK есть `IntrospectionTokenVerifier`, который обращается - к эндпоинту [RFC 7662](https://datatracker.ietf.org/doc/html/rfc7662) настоящего сервера авторизации. Именно так устроено большинство верификаторов в продакшене. + В репозитории SDK, в `examples/servers/simple-auth/`, есть `IntrospectionTokenVerifier`, который обращается + к эндпоинту [RFC 7662](https://datatracker.ietf.org/doc/html/rfc7662) настоящего сервера авторизации. Так устроено большинство верификаторов в реальных развёртываниях. -## Что вы получаете через HTTP {#what-you-get-over-http} +## Что появляется по HTTP {#what-you-get-over-http} -Авторизация живёт в HTTP-заголовках, поэтому существует только на HTTP-транспортах. Запускайте её на том транспорте, который развёртываете: `mcp.run(transport="streamable-http")` поднимает её на `http://127.0.0.1:8000/mcp`, а остальное — на странице **[Запуск сервера](index.md)**. Теперь у приложения два маршрута: +Авторизация живёт в HTTP-заголовках, поэтому существует только на HTTP-транспортах. Запускайте её на том, который развёртываете: `mcp.run(transport="streamable-http")` поднимает сервер на `http://127.0.0.1:8000/mcp`, а остальное — на странице **[Запуск сервера](index.md)**. Теперь у приложения два маршрута: ```text /mcp @@ -65,11 +69,11 @@ SDK ничего не предполагает о том, как выгляди } ``` -Именно по этому документу клиент, который никогда не слышал о вашем сервере, находит к нему дорогу: читает `authorization_servers` и идёт туда за токеном. Ничего из этого вы не писали. +По этому документу клиент, никогда не слышавший о вашем сервере, находит дорогу внутрь: читает `authorization_servers` и идёт туда за токеном. Ни строчки из него вы не писали. !!! check - Обратитесь к `/mcp` без токена (или с таким, для которого верификатор вернул `None`) — и запрос - остановят на входе: + Вызовите `/mcp` без токена (или с токеном, для которого верификатор вернул `None`) — и запрос + остановят на пороге: ```text HTTP/1.1 401 Unauthorized @@ -78,26 +82,26 @@ SDK ничего не предполагает о том, как выгляди {"error": "invalid_token", "error_description": "Authentication required"} ``` - Ничего не разбиралось, ни один инструмент не запускался. А указатель `resource_metadata` в `WWW-Authenticate` — - это то, что делает обнаружение автоматическим: 401 -> документ метаданных -> сервер авторизации -> токен -> повтор. + Ничего не было разобрано, и ни один инструмент не выполнился. А указатель `resource_metadata` в `WWW-Authenticate` — + именно то, что делает обнаружение автоматическим: 401 -> документ метаданных -> сервер авторизации -> токен -> повтор запроса. !!! warning Ничто из этого не защищает `stdio`. У канала нет заголовка `Authorization`, поэтому к `token_verifier` там никогда - не обращаются. Граница безопасности `stdio`-сервера — это процесс, который его запустил. То же - относится к `Client(mcp)` в памяти, который используется в тестах: он подключается напрямую к объекту сервера + не обращаются. Граница безопасности `stdio`-сервера — процесс, который его запустил. То же + относится к `Client(mcp)` в памяти, который используется в тестах: он подключается прямо к объекту сервера и минует HTTP-уровень вместе с авторизацией. -## Личность вызывающего {#the-callers-identity} +## Личность вызывающей стороны {#the-callers-identity} Внутри любого обработчика **`get_access_token()`** — это `AccessToken`, который ваш верификатор вернул для текущего запроса: -```python title="server.py" hl_lines="4 32-35" +```python title="server.py" hl_lines="4 35-38" --8<-- "docs_src/authorization/tutorial002.py" ``` -* Это работает в инструментах, ресурсах и промптах, и ничего передавать не нужно: middleware авторизации сохраняет его в контекстной переменной для каждого запроса. -* Возвращается **тот самый объект, который собрал ваш верификатор**: `client_id`, `scopes`, `subject`, `expires_at` и любые дополнительные `claims`, которые вы прикрепили. Это и есть точка для правил на уровне отдельных инструментов: прочитайте scopes и откажите. -* Вне аутентифицированного HTTP-запроса функция возвращает `None`. В памяти и через `stdio` это всегда `None`. +* Работает в инструментах, ресурсах и промптах, и передавать ничего не нужно: middleware авторизации сохраняет его в контекстной переменной для каждого запроса. +* Возвращается **тот самый объект, который собрал ваш верификатор**: `client_id`, `scopes`, `subject`, `expires_at` и любые дополнительные `claims`, которые вы прикрепили. Это и есть точка для правил на уровне инструмента: прочитайте области действия и откажите. +* Вне аутентифицированного HTTP-запроса возвращается `None`. В памяти и по `stdio` это всегда `None`. Вызовите `whoami` с `Authorization: Bearer alice-token` — и модель прочитает: @@ -105,26 +109,26 @@ SDK ничего не предполагает о том, как выгляди alice (scopes: notes:read) ``` -## Половина, которую SDK не делает {#the-half-the-sdk-doesnt-do} +## Половина, которой в SDK нет {#the-half-the-sdk-doesnt-do} -SDK даёт вам половину сервера ресурсов: проверить, объявить, отказать. Он не даёт страницу входа, экран согласия или токен. +SDK даёт вам половину сервера ресурсов: проверить, объявить, отказать. Он не даёт ни страницы входа, ни экрана согласия, ни токена. -Чтобы увидеть все три стороны в действии, запустите `examples/servers/simple-auth/` из репозитория SDK (небольшой сервер авторизации и сервер ресурсов, настроенный ровно так, как на этой странице), а затем направьте на него `examples/clients/simple-auth-client/` — и пройдите весь путь от обнаружения до токена. +Чтобы увидеть в движении все три стороны, запустите `examples/servers/simple-auth/` из репозитория SDK (небольшой сервер авторизации и сервер ресурсов, настроенный ровно как на этой странице), а затем направьте на него `examples/clients/simple-auth-client/` — получится полный цикл обнаружения и получения токена. !!! info - Есть второй аргумент конструктора, `auth_server_provider=`, который встраивает полноценный сервер - авторизации внутрь MCP-сервера. Он появился раньше разделения AS/RS, вокруг которого построена спецификация - авторизации MCP. В новых серверах к нему прибегать не следует. + Есть и второй аргумент конструктора, `auth_server_provider=`, который встраивает полноценный сервер + авторизации внутрь MCP-сервера. Он появился раньше разделения на AS и RS, вокруг которого построена + спецификация авторизации MCP. В новых серверах обращаться к нему не следует. -Сервер авторизации также может принять подписанное утверждение от корпоративного провайдера идентификации вместо того, чтобы пользователь проходил через экран согласия, и SDK поддерживает обе стороны этого обмена. Сам грант и клиент, который его предъявляет, описаны на странице **[Утверждение личности](../client/identity-assertion.md)**. +Сервер авторизации может также принять подписанное утверждение корпоративного провайдера идентификации вместо того, чтобы пользователь проходил экран согласия, — и SDK поддерживает обе стороны этого обмена. Сам грант и клиент, который его предъявляет, описаны на странице **[Утверждение идентичности](../client/identity-assertion.md)**. ## Итоги {#recap} -* Через Streamable HTTP ваш сервер — это **сервер ресурсов** OAuth 2.1: он проверяет токены, но никогда их не выдаёт. -* `TokenVerifier` — вся поверхность интеграции: один асинхронный метод, токен на входе, `AccessToken | None` на выходе. +* По Streamable HTTP ваш сервер — **сервер ресурсов** OAuth 2.1: он проверяет токены и никогда их не выдаёт. +* `TokenVerifier` — вся поверхность интеграции: один асинхронный метод, на входе токен, на выходе `AccessToken | None`. * `token_verifier=` и `auth=AuthSettings(issuer_url=..., resource_server_url=..., required_scopes=[...])` всегда идут в паре. -* SDK публикует Protected Resource Metadata по [RFC 9728](https://datatracker.ietf.org/doc/html/rfc9728) по адресу `/.well-known/oauth-protected-resource/...` и отвечает на неаутентифицированные запросы кодом 401, заголовок `WWW-Authenticate` которого указывает на них. Это и есть вся история обнаружения. +* SDK публикует Protected Resource Metadata по [RFC 9728](https://datatracker.ietf.org/doc/html/rfc9728) по адресу `/.well-known/oauth-protected-resource/...` и отвечает на неаутентифицированные запросы кодом 401, заголовок `WWW-Authenticate` которого указывает на этот документ. В этом и состоит всё обнаружение. * `get_access_token()` в любом обработчике — это тот, кто вызывает. -* Авторизация — забота HTTP. `stdio` и клиент в памяти её никогда не видят. +* Авторизация — дело HTTP. `stdio` и тестовый клиент в памяти её никогда не видят. -Клиентская половина (обнаружение вашего сервера авторизации и получение токена за вас) — на странице **[OAuth-клиенты](../client/oauth-clients.md)**. А клиент, который *утверждает* личность вместо того, чтобы запрашивать её у пользователя, — на странице **[Утверждение личности](../client/identity-assertion.md)**. +Клиентская половина (найти ваш сервер авторизации и получить токен за вас) — на странице **[OAuth-клиенты](../client/oauth-clients.md)**. А клиент, который *утверждает* идентичность, вместо того чтобы спрашивать её у пользователя, — на странице **[Утверждение идентичности](../client/identity-assertion.md)**. diff --git a/i18n/ru/pages/run/deploy.md b/i18n/ru/pages/run/deploy.md index 1671d923..1ddeb70d 100644 --- a/i18n/ru/pages/run/deploy.md +++ b/i18n/ru/pages/run/deploy.md @@ -1,6 +1,6 @@ --- translation: - sections: [28221886b198784f, f88ea1f1614f3a1d, ce926d686730b6d0, 3be24f8ad8bb5ab9, 3fad24032b2224ff, f25a7f860e579ecb, e758745df6fb7b0a] + sections: [28221886b198784f, f88ea1f1614f3a1d, 2e76f5cb9df15042, ce926d686730b6d0, 3be24f8ad8bb5ab9, 3fad24032b2224ff, f25a7f860e579ecb, 697b01d95080880d] tool: 1 --- # Развёртывание и масштабирование {#deploy-scale} @@ -47,6 +47,22 @@ translation: Свежеразвёрнутый сервер, который отклоняет все подключения, — это список разрешённых Host, пока не доказано обратное. **[Устранение неполадок](../troubleshooting.md)** тоже начинается отсюда. +## За прокси, терминирующим TLS {#behind-a-tls-terminating-proxy} + +Если TLS завершается на прокси (ingress, балансировщик нагрузки, Caddy, nginx), а uvicorn за ним отдаёт обычный HTTP, велите uvicorn доверять заголовкам `X-Forwarded-*` от прокси: + +```console +uvicorn server:app --proxy-headers --forwarded-allow-ips='' +``` + +Без этого приложение считает, что его отдают по `http://`, и любое перенаправление, которое оно выдаёт (обычно это `/mcp` → `/mcp/`), указывает на `http://…`. Python-клиент отказывается переходить с HTTPS-эндпоинта на обычный HTTP и прямо об этом говорит: + +```text +MCPError: Redirect to http://mcp.example.com/mcp/ not followed: it would downgrade this HTTPS endpoint to plain HTTP. +``` + +Временное решение на стороне клиента — указать точный URL, который обслуживает сервер (`https://mcp.example.com/mcp/`, со слэшем на конце), чтобы перенаправления не было вовсе. Настоящее решение — флаг выше. `FORWARDED_ALLOW_IPS` — то же самое в виде переменной окружения; `*` доверяет каждому узлу по пути, что правильно, только если до uvicorn не может добраться никто, кроме прокси. + ## Рабочие процессы и кому нужна привязка {#workers-and-who-has-to-be-sticky} Как только доменное имя отвечает, поставьте за ним больше одного рабочего процесса. В SDK для этого нет никакой ручки; приложение Starlette масштабируется так же, как любое ASGI-приложение: объект передаётся тому, кто умеет порождать процессы: @@ -171,6 +187,7 @@ python -c "import secrets; print(secrets.token_hex(32))" ## Итоги {#recap} * По умолчанию приложение отвечает только на запросы, адресованные localhost. `transport_security=TransportSecuritySettings(allowed_hosts=[...], allowed_origins=[...])` — это ворота в продакшен: пока вы его не передадите, каждый запрос за настоящим доменным именем получает `421`, а причина есть только в логе сервера. +* За прокси, терминирующим TLS, запускайте uvicorn с `--proxy-headers --forwarded-allow-ips=...`, иначе его перенаправления указывают на `http://`, и клиент их отклоняет. * На 2026-07-28 нет сессии, и балансировщику не к чему привязываться. `stateless_http=True` — ручка только для старого поколения, потому что современный запрос маршрутизируется и получает ответ раньше, чем этот флаг вообще читается. * Ключ `requestState` по умолчанию — `os.urandom(32)`, выпускаемый в каждом процессе. Многораундовый повтор, попавший к другому рабочему процессу, падает с `-32602` *«Invalid or expired requestState»*. * Решение — `RequestStateSecurity(keys=[...])` **и** одно и то же имя сервера на каждом экземпляре. Имя — это audience claim токена по умолчанию. Одни ключи, одно имя. diff --git a/i18n/ru/pages/run/index.md b/i18n/ru/pages/run/index.md index 879daf72..a4ad8692 100644 --- a/i18n/ru/pages/run/index.md +++ b/i18n/ru/pages/run/index.md @@ -1,6 +1,6 @@ --- translation: - sections: [fea8d769ff9edeba, ce8e2ad42f29ef71, 0d705efb19cf99c2, 2fd7cf825e6d2b2c, 9adc400e8c88e854, 318893ad8e2e9924, 6b63ab96b34476c0] + sections: [fea8d769ff9edeba, ce8e2ad42f29ef71, 0d705efb19cf99c2, 9fd2357154a5b7e7, 9adc400e8c88e854, 318893ad8e2e9924, 6b63ab96b34476c0] tool: 1 --- # Запуск сервера {#running-your-server} @@ -75,6 +75,11 @@ Inspector делает ровно то же, что и настоящий хос * `max_request_body_size`: максимальный принимаемый размер тела запроса в байтах. По умолчанию 4 МиБ; более крупные запросы получают HTTP 413 ещё до разбора и создания сессии. Увеличивайте его, только если легитимные MCP-сообщения превышают этот размер. +* `session_idle_timeout`: сколько секунд сессия старого поколения может простаивать без единого выполняющегося запроса, прежде чем + сервер её закроет. По умолчанию 1800. `None` отключает таймаут. См. + [Время жизни сессий и лимиты](legacy-clients.md#session-lifetime-and-limits). +* `max_sessions`: сколько сессий старого поколения один процесс удерживает одновременно. По умолчанию 10 000. `None` + снимает ограничение. Описан в том же разделе. * `event_store`, `retry_interval`, `transport_security`: возобновляемость и защита от DNS-rebinding. Они могут подождать, пока вы не развернётесь где-то кроме localhost; `transport_security` разобран на странице **[Развёртывание и масштабирование](deploy.md)**. !!! warning diff --git a/i18n/ru/pages/run/legacy-clients.md b/i18n/ru/pages/run/legacy-clients.md index 254ec014..bd6708c2 100644 --- a/i18n/ru/pages/run/legacy-clients.md +++ b/i18n/ru/pages/run/legacy-clients.md @@ -1,6 +1,6 @@ --- translation: - sections: [3d1663c18edc824c, d4fd37009a13f03d, af9f398a5a8b679a, 470c2dd144294d69, 8e45827e6d24e8c8, 91dfd0ce98ebb03c] + sections: [3d1663c18edc824c, 90956965ae6a1ca1, af9f398a5a8b679a, 5ce83b1f9d88da62, 0d9b5d13fffc94e5, 8e45827e6d24e8c8, 91dfd0ce98ebb03c] tool: 1 --- # Обслуживание клиентов старого поколения {#serving-legacy-clients} @@ -21,15 +21,25 @@ SDK маршрутизирует каждый запрос по его заго ## Один обработчик, оба поколения {#one-handler-both-eras} -Вот инструмент, которому нужно кое-что спросить у пользователя, и клиенты обоих поколений, которые его вызывают: +Вот инструмент, которому нужно кое-что спросить у пользователя: -```python title="server.py" hl_lines="24 37-38" +```python title="server.py" hl_lines="21" --8<-- "docs_src/legacy_clients/tutorial001.py" ``` Инструменту `reserve` нужно одно, чего модель не сообщила: сколько экземпляров. `Annotated[..., Resolve(ask_quantity)]` — так инструмент это объявляет (подробнее — на странице **[Зависимости](../handlers/dependencies.md)**). Ничто в `reserve` не называет версию, не проверяет возможность и не ветвится. -Оба клиента открыты **одновременно**, на одном и том же объекте `mcp`. `mode="legacy"` выполняет рукопожатие `initialize` — ровно такое подключение открывает клиент до 2026 года. Второй клиент берёт значение по умолчанию и оказывается на `2026-07-28`. +Запустите его по HTTP — и вот клиенты обоих поколений, которые его вызывают: + +```console +uv run mcp run server.py --transport streamable-http +``` + +```python title="client.py" hl_lines="14-15" +--8<-- "docs_src/legacy_clients/tutorial001_client.py" +``` + +Оба клиента открыты **одновременно**, к одному и тому же работающему серверу. `mode="legacy"` выполняет рукопожатие `initialize` — ровно такое подключение открывает клиент до 2026 года. Второй клиент берёт значение по умолчанию и оказывается на `2026-07-28`. Запустите `python client.py` во втором терминале: ```text 2025-11-25 {'result': "Reserved 2 of 'Dune'."} @@ -62,6 +72,40 @@ SDK маршрутизирует каждый запрос по его заго отправка пропущенных SSE-событий клиенту, который переподключается к *той же* сессии), а не хранилище сессий. Сессию доступной из другого процесса он не делает никогда. +## Время жизни и лимиты сессий {#session-lifetime-and-limits} + +Сессия старого поколения не живёт вечно, и один процесс не держит их неограниченное количество. +За это отвечают две настройки. Обе — именованные аргументы `run()`, `streamable_http_app()` +и `Server.streamable_http_app()`. У современных подключений (`2026-07-28`) и при `stateless_http=True` +сессий нет, так что ни одна из настроек к ним не относится. + +| Настройка | По умолчанию | Что делает | Что видит клиент | Как отключить | +|---|---|---|---|---| +| `session_idle_timeout` | `1800` (30 мин) | Закрывает сессию, в которой столько времени ничего не было в работе. | `404 Session not found`. Придётся заново выполнить `initialize`. | `None` | +| `max_sessions` | `10_000` | Отказывается открывать сессии сверх этого числа. Существующие сессии не трогает и ничего не вытесняет. | `503 Too many open sessions` с кодом JSON-RPC `-32603`. | `None` | + +Что считается «в работе»: + +* Открытый `GET`-поток. Клиенты SDK держат такой поток открытым, поэтому сессия подключённого + клиента никогда не истекает. +* Запрос, на который ещё готовится ответ. Вызов инструмента, работающий дольше тайм-аута, + не прерывается, а обратный отсчёт начинается только после его завершения. +* Больше ничего. Между запросами часы идут. Любой запрос в сессии запускает их заново, + включая `ping`. Истёкшую сессию уже ничто не оживит. + +Клиент, завершающий сессию запросом `DELETE`, освобождает её сразу. То же происходит +с клиентом, чей открывающий запрос был отклонён. + +```python +mcp.run(transport="streamable-http", session_idle_timeout=None, max_sessions=50_000) +``` + +Оба события попадают в лог сервера. Истечение — `Session idle timeout` на уровне `INFO`. +Отказ в открытии — `Refusing to open a new session: sessions are already open` на уровне `WARNING`. + +Лимиты действуют на процесс. При четырёх рабочих процессах потолок — четыре раза по `max_sessions`, +и каждый рабочий процесс сам отсчитывает время жизни своих сессий. + ## Единственный переключатель: `stateless_http` {#the-one-knob-stateless_http} Если привязка — цена, которую вы платить не готовы, изменить можно ровно одно. @@ -87,7 +131,7 @@ SDK маршрутизирует каждый запрос по его заго !!! check Сделайте заведомо неправильно. `reserve` — тот самый инструмент, который только что обслужил - оба клиента. Разверните его с `stateless_http=True`, подключите те же два клиента по HTTP и + оба клиента. Разверните его с `stateless_http=True`, подключите те же два клиента и вызовите его из каждого. Современный клиент по-прежнему получает `Reserved 2 of 'Dune'.` Современная ветка не изменилась. diff --git a/i18n/ru/pages/troubleshooting.md b/i18n/ru/pages/troubleshooting.md index 189c9d0a..b364dc4a 100644 --- a/i18n/ru/pages/troubleshooting.md +++ b/i18n/ru/pages/troubleshooting.md @@ -1,6 +1,6 @@ --- translation: - sections: [2efaecdef109a5c5, fcacd3e66b8635a4, 25323d737dcf0261, 8a6e351ec756904d, 137454d469c867f5, 6392596bd6df54f0, 41126fa9c4fe432f, 480b6d7897e30ab4, d83bb682e708dde0, ebbed3449c499db4, 323ef84f6b4bebde, 30fd31be74169d9a, 656943c6cb567218, c2dc3b1007d2e987, 7cf5386b997d04e9, 0b59feed8384456e, 0cba47bae78d04eb, e4355f4c7cf4fb2e] + sections: [3d58228e81b99543, 170514ce901c4139, 17d61fad0a50d62b, 8a6e351ec756904d, 137454d469c867f5, 6392596bd6df54f0, 41126fa9c4fe432f, 480b6d7897e30ab4, d83bb682e708dde0, ebbed3449c499db4, 525cdf1755e29d4c, 30fd31be74169d9a, d2e88333d4f7841f, c2dc3b1007d2e987, d6eabf60cc366341, f798e815252852c2, 0cba47bae78d04eb, 2c218ba829abf74e] tool: 1 --- # Устранение неполадок {#troubleshooting} @@ -13,6 +13,12 @@ translation: --8<-- "docs_src/troubleshooting/tutorial001.py" ``` +Эти записи обращаются к нему по адресу `http://localhost:8000/mcp`, так что оставьте его запущенным по HTTP: + +```console +uv run mcp run server.py --transport streamable-http +``` + Ошибки, которые цитирует эта страница, настоящие: собственный набор тестов SDK воспроизводит каждую из них. ## `ExceptionGroup: unhandled errors in a TaskGroup (1 sub-exception)` {#exceptiongroup-unhandled-errors-in-a-taskgroup-1-sub-exception} @@ -23,7 +29,7 @@ translation: ```python async def main() -> None: - async with Client(mcp) as client: + async with Client("http://localhost:8000/mcp") as client: await client.read_resource("weather://Atlantis") ``` @@ -49,7 +55,7 @@ async def main() -> None: ```python async def main() -> None: - async with Client(mcp) as client: + async with Client("http://localhost:8000/mcp") as client: try: await client.read_resource("weather://Atlantis") except MCPError as e: @@ -67,7 +73,7 @@ async def main() -> None: ```python async def main() -> None: - client = Client(mcp) + client = Client("http://localhost:8000/mcp") tools = await client.list_tools() # RuntimeError ``` @@ -75,7 +81,7 @@ async def main() -> None: ```python async def main() -> None: - async with Client(mcp) as client: + async with Client("http://localhost:8000/mcp") as client: tools = await client.list_tools() ``` @@ -251,7 +257,7 @@ app = Starlette(routes=[Mount("/", app=mcp.streamable_http_app())], lifespan=lif ## `MCPError: Session not found` {#mcperror-session-not-found} -Сервер не узнаёт `Mcp-Session-Id`, который отправил клиент, — почти всегда потому, что сервер **перезапустился** (или вас направили на другой экземпляр). Сессии живут в памяти одного этого процесса. +Сервер не узнаёт `Mcp-Session-Id`, который отправил клиент. Либо сервер **перезапустился** (или вас направили на другой экземпляр), либо сессия **истекла**, потому что в течение `session_idle_timeout` — по умолчанию 30 минут — не выполнялось ни одного запроса. См. раздел [Время жизни сессии и ограничения](run/legacy-clients.md#session-lifetime-and-limits). Сессии живут в памяти одного этого процесса. Искать ошибку в сервере незачем. HTTP-ответ — `404`, тело которого — *настоящий* JSON-RPC, поэтому, в отличие от `421` выше, `Client` на Python показывает его дословно: @@ -261,9 +267,9 @@ app = Starlette(routes=[Mount("/", app=mcp.streamable_http_app())], lifespan=lif Исправление — переподключиться: выйти из блока `async with Client(...)` и войти в новый, который согласует свежую сессию. Для долгоживущего клиента это означает перехватывать `MCPError` вокруг вызовов и переподключаться по этому сообщению, а не повторять попытки внутри мёртвой сессии. -Если это происходит *без* перезапуска, значит, у вас больше одного воркера без закрепления сессий за ними: каждый воркер держит собственную таблицу сессий, поэтому запрос, направленный не на тот воркер, оказывается здесь. Эта история и два её решения (маршрутизация с привязкой сессий или `stateless_http=True`) — на страницах **[Развёртывание и масштабирование](run/deploy.md)** и **[Обслуживание клиентов старого поколения](run/legacy-clients.md)**. +Если это происходит *без* перезапуска и клиент не молчал так долго, значит, у вас больше одного воркера без закрепления сессий за ними: каждый воркер держит собственную таблицу сессий, поэтому запрос, направленный не на тот воркер, оказывается здесь. Эта история и два её решения (маршрутизация с привязкой сессий или `stateless_http=True`) — на страницах **[Развёртывание и масштабирование](run/deploy.md)** и **[Обслуживание клиентов старого поколения](run/legacy-clients.md)**. -Для оператора сервера соответствующая строка лога — `Rejected request with unknown or expired session ID: `. Она пишется на уровне `INFO`, поэтому при обычном пороге `WARNING` её не видно. Видеть её пачками сразу после развёртывания — нормально: все подключённые клиенты переподключаются. +Для оператора сервера соответствующая строка лога — `Rejected request with unknown or expired session ID: `. Она пишется на уровне `INFO`, поэтому при обычном пороге `WARNING` её не видно. Видеть её пачками сразу после развёртывания — нормально: все подключённые клиенты переподключаются. Если же сессия истекла, этой строке предшествует `Session idle timeout`, тоже на уровне `INFO`. ## `MCPError: Method not found` {#mcperror-method-not-found} @@ -275,7 +281,13 @@ app = Starlette(routes=[Mount("/", app=mcp.streamable_http_app())], lifespan=lif Сервер хочет что-то спросить у пользователя, а этот клиент никогда не говорил, что его можно спрашивать. -Резолвер элицитации (elicitation) отказывает заранее, если подключённый клиент не объявил элицитацию через формы, и `e.error.data` называет ровно то, чего не хватает: +Этот Bistro перед бронированием задаёт вопрос через резолвер: + +```python title="server.py" hl_lines="15-17 21" +--8<-- "docs_src/troubleshooting/tutorial007.py" +``` + +Запустите его вместо сервера Weather и вызовите `book_table` из клиента, который не передал `elicitation_callback`. Резолвер отказывает заранее, потому что подключённый клиент так и не объявил элицитацию (elicitation) через формы, а `e.error.data` называет ровно то, чего не хватает: ```json { @@ -289,7 +301,7 @@ app = Starlette(routes=[Mount("/", app=mcp.streamable_http_app())], lifespan=lif ```python async def main() -> None: - async with Client(mcp, elicitation_callback=handle_elicitation) as client: + async with Client("http://localhost:8000/mcp", elicitation_callback=handle_elicitation) as client: result = await client.call_tool("book_table", {"date": "Friday"}) ``` @@ -314,14 +326,14 @@ async def main() -> None: Обработчик попытался обратиться к клиенту посреди запроса на подключении, где у вызова нет канала, способного донести запрос от сервера. В такое положение вызов ставят три конфигурации сервера. -**Подключение `2026-07-28`: любой транспорт, всегда.** В современном протоколе вообще нет запросов, инициируемых сервером, поэтому сервер отказывает ещё до того, как что-либо отправлено. `ctx.elicit()` внутри инструмента — классический способ с этим столкнуться (в самом первом тесте в памяти, поскольку `Client(server)` согласовывает `2026-07-28`, не спрашивая), и передача `elicitation_callback=` ничего не меняет: никакой запрос до клиента не доходит, так что отвечать ему не на что: +**Подключение `2026-07-28`: любой транспорт, всегда.** В современном протоколе вообще нет запросов, инициируемых сервером, поэтому сервер отказывает ещё до того, как что-либо отправлено. `ctx.elicit()` внутри инструмента — классический способ с этим столкнуться, обычно в самом первом **[тесте](get-started/testing.md)** этого инструмента в памяти, ведь `Client(mcp)` согласовывает `2026-07-28`, даже если его об этом не просили. Передача `elicitation_callback=` ничего не меняет: никакой запрос до клиента не доходит, так что отвечать ему не на что: ```python title="server.py" hl_lines="16" --8<-- "docs_src/troubleshooting/tutorial006.py" ``` ```python -async def main() -> None: +async def test_book_table() -> None: async with Client(mcp) as client: await client.call_tool("book_table", {"date": "Friday"}) ``` @@ -363,7 +375,7 @@ mcp.shared.exceptions.MCPError: Cannot send 'elicitation/create': this transport ```python async def main() -> None: - async with Client(mcp) as client: + async with Client("http://localhost:8000/mcp") as client: await client.call_tool("forecast", {"city": "London"}, request_state="round-1-from-worker-a") ``` @@ -416,7 +428,7 @@ mcp = MCPServer("Weather", request_state_security=RequestStateSecurity(keys=[key * `Tool already exists:` в логе сервера — единственный признак того, что два одноимённых инструмента схлопнулись в один. * Один 421, три написания: `Server returned an error response` (`Client` на Python), `421 Misdirected Request` / `Invalid Host header` (всё остальное), `Invalid Host header: ` (лог сервера). Исправление: `transport_security=TransportSecuritySettings(allowed_hosts=[...])`. * `Task group is not initialized` -> смонтированное приложение, жизненный цикл хоста которого так и не вошёл в `mcp.session_manager.run()`. -* `Session not found` -> сервер перезапустился; переподключитесь. +* `Session not found` -> сервер перезапустился или сессия истекла (`session_idle_timeout`); переподключитесь. * `Cannot send 'elicitation/create': ... no back-channel ...` -> `ctx.elicit()` нужен канал от сервера к клиенту: у подключения `2026-07-28` его не бывает никогда, `stateless_http=True` отнимает его у подключений старого поколения, а `json_response=True` отнимает канал, привязанный к запросу. Используйте резолвер (клиенту старого поколения к тому же нужен сервер, который сохраняет канал). Соседнее `Method not found` — это запрос метода, которого нет в ревизии протокола другой стороны. * `Client did not declare the form elicitation capability ...` и `Elicitation not supported` -> у клиента не хватает `elicitation_callback=`. * `Invalid or expired requestState` никогда не говорит по сети, почему. Лог сервера говорит; `unknown key` означает, что `RequestStateSecurity(keys=[...])` нужно сделать общим для всех воркеров. diff --git a/i18n/ru/pages/whats-new.md b/i18n/ru/pages/whats-new.md index 2e9f324a..3382e2d0 100644 --- a/i18n/ru/pages/whats-new.md +++ b/i18n/ru/pages/whats-new.md @@ -1,6 +1,6 @@ --- translation: - sections: [cfe01c0c5863dfa2, 1c58c5cfcc37d455, a7392996acf1ad8f, 875eb2889263424e] + sections: [cfe01c0c5863dfa2, 0dbb68d8b210177b, 80cf193023af3ed4, 875eb2889263424e] tool: 1 --- # Что нового в v2 {#whats-new-in-v2} @@ -42,11 +42,11 @@ mcp = MCPServer("Demo") # v1: FastMCP("Demo") v1 выдавала три вложенных слоя: контекстный менеджер транспорта, отдающий сырые потоки, обёрнутый вокруг них `ClientSession` и вызываемый вручную `await session.initialize()`. В v2 объект один: -```python title="client.py" hl_lines="14-18" ---8<-- "docs_src/client/tutorial001.py" +```python title="client.py" hl_lines="7-11" +--8<-- "docs_src/client/tutorial001_client.py" ``` -`Client` принимает объект сервера (в памяти, без транспорта — это сценарий для тестов), URL (Streamable HTTP), `StdioServerParameters` (подпроцесс stdio) или любой другой контекстный менеджер транспорта, например `sse_client(...)`. Вход в `async with` подключается и согласует версию протокола, на каком бы поколении ни говорил сервер; после этого `client.server_capabilities` и `client.protocol_version` просто доступны, как и `client.server_info`, когда сервер себя идентифицирует (теперь это `Implementation | None`, поскольку в поколении 2026 идентификация необязательна). Колбэки сэмплирования и элицитации, зарегистрированные в v1, по-прежнему работают (их тела затрагивает то же переименование атрибутов в snake_case, что и всё остальное на этой странице), теперь они ещё и отвечают на запросы внутри результатов в стиле 2026 (см. ниже) и выполняются параллельно, а не по одному. `ClientSession` по-прежнему лежит в основе для тех, кому нужна низкоуровневая поверхность, и `client.session` её отдаёт; она тоже изменилась (работает на новом движке-диспетчере, и некоторые её собственные сигнатуры поменялись), так что прежде чем спускаться на этот уровень, прочитайте **[Руководство по миграции](migration.md#clientsession-now-runs-on-jsonrpcdispatcher-basesession-removed)**. +`Client` принимает URL (Streamable HTTP), `StdioServerParameters` (подпроцесс stdio), любой другой контекстный менеджер транспорта, например `sse_client(...)`, или — в тестах — сам объект сервера (в памяти, без транспорта). Вход в `async with` подключается и согласует версию протокола, на каком бы поколении ни говорил сервер; после этого `client.server_capabilities` и `client.protocol_version` просто доступны, как и `client.server_info`, когда сервер себя идентифицирует (теперь это `Implementation | None`, поскольку в поколении 2026 идентификация необязательна). Колбэки сэмплирования и элицитации, зарегистрированные в v1, по-прежнему работают (их тела затрагивает то же переименование атрибутов в snake_case, что и всё остальное на этой странице), теперь они ещё и отвечают на запросы внутри результатов в стиле 2026 (см. ниже) и выполняются параллельно, а не по одному. `ClientSession` по-прежнему лежит в основе для тех, кому нужна низкоуровневая поверхность, и `client.session` её отдаёт; она тоже изменилась (работает на новом движке-диспетчере, и некоторые её собственные сигнатуры поменялись), так что прежде чем спускаться на этот уровень, прочитайте **[Руководство по миграции](migration.md#clientsession-now-runs-on-jsonrpcdispatcher-basesession-removed)**. Страница **[Объект Client](client/index.md)** знакомит с ним, **[Транспорты клиента](client/transports.md)** описывает четыре формы подключения, **[Колбэки клиента](client/callbacks.md)** — сами колбэки, а **[Тестирование](get-started/testing.md)** показывает шаблон работы в памяти, который заменяет вспомогательную функцию `create_connected_server_and_client_session()` из v1. @@ -171,11 +171,15 @@ v2 реализует ревизию 2026-07-28 и обслуживает **об Замена разворачивает вызов. Инструмент, которому что-то нужно от пользователя, *возвращает* вопрос (`InputRequiredResult`), клиент отвечает на него теми же колбэками, что были всегда, и вызов повторяется с приложенными ответами. `Client` ведёт этот цикл за вас. На сервере вы редко собираете результат сами, потому что это делает **[зависимость](handlers/dependencies.md)**: аннотируйте параметр `Resolve(ask_quantity)`, где `ask_quantity` — обычная функция, которую вы пишете, и SDK спросит тем механизмом, который поддерживает подключение: живым запросом элицитации на сессии старого поколения или многораундовым запросом на 2026. Одно тело инструмента, оба поколения: -```python title="dual_era.py" hl_lines="24 37-38" +```python title="server.py" hl_lines="21" --8<-- "docs_src/legacy_clients/tutorial001.py" ``` -В этом файле вся идея собрана в одном месте: один сервер, один инструмент на `Resolve`, а также клиент старого поколения и современный клиент, оба получающие свой ответ, — всё в памяти. **[Многораундовые запросы](handlers/multi-round-trip.md)** объясняет механизм (включая `request_state`, который SDK запечатывает и проверяет за вас); **[Элицитация](handlers/elicitation.md)** описывает, как спрашивать. +```python title="client.py" hl_lines="14-15" +--8<-- "docs_src/legacy_clients/tutorial001_client.py" +``` + +В этих двух файлах вся идея целиком: один сервер, один инструмент на `Resolve`, а также клиент старого поколения и современный клиент, оба получающие свой ответ от одного и того же работающего сервера (**[Обслуживание клиентов старого поколения](run/legacy-clients.md)** разбирает их по шагам). **[Многораундовые запросы](handlers/multi-round-trip.md)** объясняет механизм (включая `request_state`, который SDK запечатывает и проверяет за вас); **[Элицитация](handlers/elicitation.md)** описывает, как спрашивать. !!! warning "Это единственное место, где перенесённый сервер v1 меняет поведение" Первыми на это натыкаются ваши собственные тесты: `Client(mcp)` по умолчанию согласует 2026-07-28 с вашим diff --git a/i18n/tr/pages/advanced/apps.md b/i18n/tr/pages/advanced/apps.md index 4f2582c9..dbe33806 100644 --- a/i18n/tr/pages/advanced/apps.md +++ b/i18n/tr/pages/advanced/apps.md @@ -1,6 +1,6 @@ --- translation: - sections: [0355618e5f4d5fe4, 1821eaf50f2d0b64, 82e0b28ebd3abf5a, 8ac39614c094f2d0, dab6ff945501ab2a, bd5565c3b2d4f959, 96819ce3d63a0487] + sections: [0355618e5f4d5fe4, 0fefa31fb7c7b585, 5c53e7487c9c70cc, 8ac39614c094f2d0, dab6ff945501ab2a, bd5565c3b2d4f959, 96819ce3d63a0487] tool: 1 --- # MCP Apps {#mcp-apps} @@ -18,7 +18,7 @@ SDK bunu yerleşik `Apps` uzantısı (`io.modelcontextprotocol/ui`) olarak sunar ## Yüzü olan bir saat {#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" ``` @@ -39,11 +39,31 @@ Her istemci uygulamaları çizmez. Şartname bunun sizin için ne anlama geldiğ Model `content`'i okur; iframe insanlar içindir. UI destekli bir host yine de metin sonucunu modele iletir, yalnızca metin destekleyen bir istemci ise *sadece* onu alır. Yani kanonik desen tek araç, iki yanıttır. `get_time`'a bir daha bakın: -```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)` yalnızca istemci `io.modelcontextprotocol/ui` uzantısını beyan ettiğinde **ve** `mimeTypes` ayarlarında `text/html;profile=mcp-app`'i listelediğinde `True` olur. Alan zorunludur, bu yüzden onu atlayan bir istemci sayılmaz. Aynı dosyadaki `main()` tam olarak bunu beyan eder: anlaşmanın istemci tarafı, ve zengin yanıt geri gelir. +`client_supports_apps(ctx)` yalnızca istemci `io.modelcontextprotocol/ui` uzantısını beyan ettiğinde **ve** `mimeTypes` ayarlarında `text/html;profile=mcp-app`'i listelediğinde `True` olur. Alan zorunludur, bu yüzden onu atlayan bir istemci sayılmaz. Anlaşmanın istemci tarafı şöyle: + +```python title="client.py" hl_lines="8 12" +--8<-- "docs_src/apps/tutorial001_client.py" +``` + +`server.py` dosyasını HTTP üzerinden sunun, ardından istemciyi ikinci bir terminalden çalıştırın: + +```console +uv run mcp run server.py --transport streamable-http +``` + +```console +python client.py +``` + +```text +2026-06-26T12:00:00Z +``` + +Zengin yanıt geri geldi. `Client` çağrısından `extensions=[APPS_SUPPORT]` argümanını çıkarın; aynı program bunun yerine `The time is 2026-06-26T12:00:00Z.` yazdırır. Yalnızca metin destekleyen bir istemcinin görüp göreceği de budur. !!! warning Tek içerik olarak asla `"[Rendered UI]"` gibi bir yer tutucu döndürmeyin. Yedek metin işe yaramazsa araç, yalnızca metin destekleyen her istemci için ve modelin kendisi için işe yaramaz. O cümleyi yazın. diff --git a/i18n/tr/pages/advanced/extensions.md b/i18n/tr/pages/advanced/extensions.md index 0403a29f..a144b017 100644 --- a/i18n/tr/pages/advanced/extensions.md +++ b/i18n/tr/pages/advanced/extensions.md @@ -1,6 +1,6 @@ --- translation: - sections: [05891e7cc1938a13, b3c01a6af28c51ee, 7ffc91f5e38bdfe0, 717d3f235a8333a7, f471a13b2fe5d737, ed6af2df4b656dff] + sections: [05891e7cc1938a13, b3c01a6af28c51ee, 6eba6ce094f4417e, 125f28588c85dd7b, ddd8969b698ca11c, ed6af2df4b656dff] tool: 1 --- # Uzantılar {#extensions} @@ -49,7 +49,7 @@ TypeError: Stamps.identifier must be a `vendor-prefix/name` string İşe yarar en küçük uzantı, bir araç ve bir ayarlar eşlemesidir: -```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" ``` @@ -57,17 +57,23 @@ TypeError: Stamps.identifier must be a `vendor-prefix/name` string * `settings()`, `capabilities.extensions["com.example/stamps"]` konumunda duyurulan değerdir. Uzantıyı ayarsız duyurmak için `{}` (varsayılan) döndürün. * Uzantı sunucuyu hiçbir zaman almaz. Katkıları veri olarak beyan eder; bunları `MCPServer` tüketir. Değiştirilecek bir `self.server` yoktur. -Kanıtı da `main()`: doğrudan `mcp`'ye bağlanan bellek içi bir istemci: +HTTP üzerinden sunun; kanıt da bir istemcidir: -```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" +``` + +Bu sayfadaki her `server.py` bu komutla sunulur; her `client.py` de ikinci bir terminalden `python client.py` ile onun yanında çalışır. + ### Kendi metotlarınızı sunma {#serving-your-own-methods} Bir uzantı **yeni istek metotları** kaydedebilir: spesifikasyonunkilerin yanında sunulan kendi fiilleri: -```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" ``` @@ -83,14 +89,15 @@ Metotlar **yalnızca ekleme niteliğindedir**. SDK bunu çalışma zamanında de ### İstemci tarafı {#the-client-side} -Aynı dosyadaki `main()`, istemci tarafının tamamıdır; iki yarısıyla birlikte: +İstemci başlı başına bir programdır ve istemci tarafının iki yarısını da taşır: -```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)])` uzantıyı beyan eder. Beyanlar `ClientCapabilities.extensions` hâline gelir: 2026-07-28 bağlantısında eşleme istek başına `_meta` zarfında taşınır, böylece sunucu onu **her** istekte görür; eski nesil bir bağlantıda `initialize` el sıkışmasıyla taşınır. Sunucu kodu hangisi olduğuyla ilgilenmez: `require_client_extension(ctx, ...)` ve `ctx.session.check_client_capability(...)` her iki yolda da doğru kaynağı okur. * Satıcıya özgü metotlar bir katman aşağıya, `client.session.send_request(...)` düzeyine iner; `Client` yalnızca spesifikasyon fiilleri için birinci sınıf metotlar kazanır. `send_request` herhangi bir `Request` alt sınıfını kabul eder, bu yüzden satıcıya özgü istek olduğu gibi geçer. +* `SearchRequest` ve taşıdığı iki model uzantının iletim sözleşmesidir; bu yüzden istemci onları kendisi için beyan eder. Yayımlanmış bir uzantı bunları her iki tarafın da içe aktardığı bir pakette sunardı. ### `tools/call` isteğini yakalama {#intercepting-toolscall} @@ -109,12 +116,18 @@ Kanca `tools/call` isteğini sarmalar, başka hiçbir şeyi değil. Her iletiyi ## Bir istemci uzantısı kullanma {#using-a-client-extension} -**İstemci uzantısı**, aynı sözleşmenin tüketen taraftan görünüşüdür: tek bir tanımlayıcının arkasında toplanmış bir istemci tarafı davranış paketi. Örnekleri `Client(extensions=[...])` ile geçirin ve araçları normal şekilde çağırın: +**İstemci uzantısı**, aynı sözleşmenin tüketen taraftan görünüşüdür: tek bir tanımlayıcının arkasında toplanmış bir istemci tarafı davranış paketi. Buradaki sunucu `buy` çağrısını malın kendisi yerine kullanılacak bir makbuzla yanıtlar; bunu da yalnızca uzantıyı beyan etmiş bir istemci için yapar: -```python title="client.py" hl_lines="66-68" +```python title="server.py" hl_lines="22-25" --8<-- "docs_src/extensions/tutorial006.py" ``` +İstemcide örnekleri `Client(extensions=[...])` ile geçirin ve araçları normal şekilde çağırın: + +```python title="client.py" hl_lines="33-35" +--8<-- "docs_src/extensions/tutorial006_client.py" +``` + `call_tool("buy", ...)`, diğer her çağrı gibi düz bir `CallToolResult` döndürür. Uzantının değiştirdiği şu: sunucu artık `buy` çağrısını nihai bir sonuç yerine `receipt` adlı bir **sonuç biçimiyle** yanıtlayabilir ve `Receipts`, `call_tool` dönmeden önce onu tamamlar (burada makbuzu bir takip çağrısıyla kullanarak). Çağrı yerinde hiçbir şey değişmez. Uzantıyı çıkarırsanız bunların hiçbiri olmaz: sunucunun kontrol noktası onu beyan etmemiş bir istemciyi reddeder (hata -32021) ve kontrolü atlayan bir sunucudan gelen sahiplenilmiş bir biçim, spesifikasyonun tanınmayan bir `resultType` için gerektirdiği gibi doğrulamadan geçemez. Bağlantının her iki ucunda da varsayılan olarak kapalı. @@ -124,15 +137,15 @@ Uzantıyı çıkarırsanız bunların hiçbiri olmaz: sunucunun kontrol noktası ```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")]) ``` ## İstemci uzantısı yazma {#writing-a-client-extension} `ClientExtension`'dan alt sınıf türetin ve yalnızca ihtiyaç duyduklarınızı geçersiz kılın. Her birinin varsayılanı olan üç katkı türü var: `settings()`, `claims()` ve `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" ``` * Tanımlayıcı, sunucununkiyle aynı dilbilgisini izler ve sınıf tanımlandığında doğrulanır. @@ -153,12 +166,18 @@ def notifications(self) -> Sequence[NotificationBinding[Any]]: ### Uzantı fiilleri {#extension-verbs} -Bir uzantının kendi istek metotları istemci tarafında kayıt gerektirmez. Satıcıya özgü bir istek türü `mcp.types.Request`'ten türer ve [Kendi metotlarınızı sunma](#serving-your-own-methods) bölümündeki gibi `client.session.send_request` üzerinden gider. Tek bir ekleme var: bir params anahtarının `Mcp-Name` başlığında taşınması gerektiğinde (tasks gibi uzantı spesifikasyonları kendi fiilleri için bunu şart koşar) istek türü `name_param` beyan eder: +Bir uzantının kendi istek metotları istemci tarafında kayıt gerektirmez. Satıcıya özgü bir istek türü `mcp.types.Request`'ten türer ve [Kendi metotlarınızı sunma](#serving-your-own-methods) bölümündeki gibi `client.session.send_request` üzerinden gider. Uzantısı, adlandırılmış bir iş hakkında tek bir fiil sunan bir sunucu düşünün: -```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" ``` +İstemcide tek bir ekleme var: bir params anahtarının `Mcp-Name` başlığında taşınması gerektiğinde (tasks gibi uzantı spesifikasyonları kendi fiilleri için bunu şart koşar) istek türü `name_param` beyan eder: + +```python title="client.py" hl_lines="20-23 28-29" +--8<-- "docs_src/extensions/tutorial007_client.py" +``` + Oturum, `params["jobId"]` değerini her gönderim yolunda `Mcp-Name` başlığına yansıtır; eksik bir değer ise gerekli bir başlığı sessizce atlamak yerine açıkça hata verir. ## Bir uzantının yapamayacakları {#what-an-extension-cannot-do} diff --git a/i18n/tr/pages/advanced/low-level-server.md b/i18n/tr/pages/advanced/low-level-server.md index 42a7a211..ba9cd121 100644 --- a/i18n/tr/pages/advanced/low-level-server.md +++ b/i18n/tr/pages/advanced/low-level-server.md @@ -1,6 +1,6 @@ --- translation: - sections: [2c79b6338e09b7ac, 7edc43b3fae11314, 1086e77ce561cd7f, a3f71823df5efc31, 9fc7109f72201cae, d50fe7faead8cf68, 7bf25983df655b66, 6330e1f4c6029683, 2f1749c8c133fa1c, 8db7116fc8ddd0ee, ebc33704fbd74262, cd0e9c933350390e] + sections: [2c79b6338e09b7ac, 9d5d10a5f0405d0a, 1086e77ce561cd7f, a3f71823df5efc31, 9fc7109f72201cae, d50fe7faead8cf68, 7bf25983df655b66, 6330e1f4c6029683, 2f1749c8c133fa1c, 8db7116fc8ddd0ee, ebc33704fbd74262, 0fde3bcea081ba3a] tool: 1 --- # Düşük seviyeli Server {#the-low-level-server} @@ -36,18 +36,22 @@ Bu, **[Araçlar](../servers/tools.md)** sayfasında dokuz satır `@mcp.tool()` i ### Deneyin {#try-it} -Bunun için Inspector yok: `mcp dev` ve `mcp run` yalnızca `MCPServer` kabul eder. Bellek içi `Client` bunu umursamaz; düşük seviyeli bir `Server`'ı tıpkı bir `MCPServer`'ı aldığı gibi alır: +`mcp dev` ve `mcp run` yalnızca `MCPServer` kabul eder, bu yüzden bunu kendiniz sunarsınız. `server.py` dosyasının son satırı ondan sıradan bir ASGI uygulaması oluşturur, uvicorn da onu çalıştırır: -```python title="main.py" +```console +uvicorn server:app --port 8000 +``` + +Inspector'ı ya da herhangi bir istemciyi `http://localhost:8000/mcp` adresine yönlendirin: + +```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) @@ -64,6 +68,8 @@ asyncio.run(main()) * `result.structured_content` değeri `None`. Yüksek seviyeli sunucu `-> str` dönüş türünü sizin yerinize `{"result": ...}` içine sarmalar; burada sizin oluşturmadığınızı kimse oluşturmaz. * `list_tools`, **sizin** yazdığınız şemayı karakteri karakterine döndürür. Yüksek seviyeli sürümde her özellikte `"title": "Query"`, kökte de `"title": "search_booksArguments"` vardı: Pydantic'in bıraktığı izler. Burada ise ağa giden bir şey varsa onu oraya siz koymuşsunuzdur. +Testte uvicorn'u ve portu atlarsınız: `Client(server)`, düşük seviyeli bir `Server`'ı süreç içinde tıpkı bir `MCPServer`'ı aldığı gibi alır; **[Test etme](../get-started/testing.md)** sayfasının anlattığı kalıp da budur. + ## Sizin yerinize hiçbir şey denetlenmez {#nothing-is-checked-for-you} `MCPServer`, çağrıyı kendi ürettiği şemaya göre doğrulayarak hatalı bir argümanı fonksiyonunuz daha çalışmadan reddeder (**[Araçlar](../servers/tools.md)**). @@ -215,4 +221,4 @@ Bunların her biri, artık kavramlarını bildiğiniz birer fikir; her birinin k * `add_request_handler(method, params_type, handler)` her metodu sunar. `initialize` ayrılmıştır. * Bir `Server`'ın duyurduğu yetenekler, hangi işleyicileri kaydettiğinizden türetilir. -`Client(server)` iki sunucuya da aynı davrandı, çünkü ikisi aynı protokolün *ta kendisi*; bütün mesele de bu. Bir alt katman ise bir sınıf bile değil: **[Middleware](middleware.md)**. +İstemci iki sunucuya da aynı davrandı, çünkü ikisi aynı protokolün *ta kendisi*; bütün mesele de bu. Bir alt katman ise bir sınıf bile değil: **[Middleware](middleware.md)**. diff --git a/i18n/tr/pages/advanced/pagination.md b/i18n/tr/pages/advanced/pagination.md index e686ca1b..68454ac2 100644 --- a/i18n/tr/pages/advanced/pagination.md +++ b/i18n/tr/pages/advanced/pagination.md @@ -1,6 +1,6 @@ --- translation: - sections: [a9aba7a026c7bd85, ed32bda7ba9ae33a, 7e64cc5646abb91f, 22a0129ee78b3c63, d875373c06d8d2f9] + sections: [a9aba7a026c7bd85, 83e2a08b9d46a398, 9fd8a0aa384b3257, 22a0129ee78b3c63, d875373c06d8d2f9] tool: 1 --- # Sayfalama {#pagination} @@ -31,9 +31,13 @@ Sayfalama, kaynak listesi aslında bir veritabanı olan sunucu içindir: tek yan ### Deneyin {#try-it} -`Client(server)`, düşük seviyeli bir `Server`'a bellek içinde, bir `MCPServer`'a bağlandığı gibi bağlanır. +`mcp run` yalnızca bir `MCPServer` kabul eder; bu yüzden bunu kendiniz sunarsınız. `server.py` dosyasının son satırı `Server`'dan sıradan bir ASGI uygulaması oluşturur, uvicorn da onu çalıştırır: -`list_resources()`'ı argümansız çağırın. `book-1`'den `book-10`'a kadar on kaynak alırsınız ve `next_cursor`, `"10"` dizgesidir. +```console +uvicorn server:app --port 8000 +``` + +Herhangi bir istemciyi (**[İstemci](../client/index.md)** ya da Inspector) `http://localhost:8000/mcp` adresine yönlendirin ve `list_resources()`'ı argümansız çağırın. `book-1`'den `book-10`'a kadar on kaynak alırsınız ve `next_cursor`, `"10"` dizgesidir. Bunu `list_resources(cursor="10")` ile geri verin; ilk kaynak `book-11`, yeni `next_cursor` ise `"20"` olur. @@ -43,7 +47,7 @@ Onuncu sayfa, `next_cursor` değeri `None` olarak döner. Bitti. `Client` üzerindeki her `list_*` metodu (`list_tools`, `list_resources`, `list_resource_templates`, `list_prompts`) bir `cursor=` anahtar kelimesi alır. Sayfalanmış bir listeyi sonuna kadar okumak tek bir `while True`'dur: -```python title="client.py" hl_lines="26-32" +```python title="client.py" hl_lines="9-15" --8<-- "docs_src/pagination/tutorial002.py" ``` @@ -51,7 +55,7 @@ Onuncu sayfa, `next_cursor` değeri `None` olarak döner. Bitti. * `next_cursor`'a bakmadan **önce** listeyi genişletin: son sayfada da kaynaklar vardır. * Çıkış koşulu `next_cursor is None`'dır. Bunun dışındaki her şey, dokunulmadan doğrudan `cursor=`'a geri gider. -`main()`'ini çalıştırın; `100 resources` yazdırır: on tane onluk sayfa, on sayfa olduğundan hiç haberi olmayan bir döngü tarafından birleştirilmiş. +uvicorn hâlâ `server.py` dosyasını sunarken ikinci bir terminalde `python client.py` komutunu çalıştırın. `100 resources` yazdırır: on tane onluk sayfa; bunları, on sayfa olduğundan hiç haberi olmayan bir döngü birleştirir. Bu, **[İstemci](../client/index.md)** sayfasının her `list_*` fiili için gösterdiği döngünün aynısıdır ve sayfalamayan bir sunucuya karşı hiçbir maliyeti yoktur: ilk yanıtta `next_cursor`, `None` olur ve döngü bir kez çalışır. diff --git a/i18n/tr/pages/client/caching.md b/i18n/tr/pages/client/caching.md index 359fbe25..0e734d96 100644 --- a/i18n/tr/pages/client/caching.md +++ b/i18n/tr/pages/client/caching.md @@ -1,6 +1,6 @@ --- translation: - sections: [9e7b9a1710e5aeba, b74ca4c1d2ddddee, fa8714e61bf90c5a, 04db67a886b7271c, 857690fb8f876800] + sections: [9e7b9a1710e5aeba, 66a2e9acc9101d54, d3d25aa802f5145a, 04db67a886b7271c, 857690fb8f876800] tool: 1 --- # Önbellekleme ipuçları {#caching-hints} @@ -30,7 +30,7 @@ Varsayılan olarak her sonuç `ttlMs: 0, cacheScope: "private"` der: anında bay Alt düzey `Server`'da işleyiciler sonuçlarını elle oluşturur ve `ttl_ms` / `cache_scope` sonuç modellerindeki sıradan alanlardır. Bunları açıkça ayarlayan bir işleyici, alan alan, her zaman oluşturucu eşlemesine üstün gelir: -```python title="server.py" hl_lines="10 16" +```python title="server.py" hl_lines="11 17" --8<-- "docs_src/caching/tutorial002.py" ``` @@ -44,10 +44,24 @@ Sayfalandırılmış listelerle ilgili bir uyarı: protokol bir listenin **her s 2026-07-28 oturumunda `Client` ipuçlarına sizin yerinize uyar: varsayılan olarak açık, yerleşik bir yanıt önbelleği vardır. `ttlMs` taşıyarak gelen bir sonuç saklanır ve o TTL içinde yapılan özdeş bir çağrı hiç tur atılmadan önbellekten sunulur. *Hiç* ipucu taşımayan bir sonuç önbelleğe alınmaz: ipucu taşımayan sonuçlar `CacheConfig.default_ttl_ms` değerini alır, bu da varsayılan olarak `0`'dır (anında bayat); dolayısıyla hiçbir şey beyan etmeyen bir sunucu, her zaman gördüğü çağrı başına bir istek trafiğinin aynısını görür. -```python title="client.py" hl_lines="33 35 38" +Bunu izlemek için önceki bölümdeki `server.py` dosyasını uvicorn ile sunun (son satırı ASGI uygulamasını oluşturur). İşleyici, gerçekten her çalıştığında bir satır yazdırır: + +```console +uvicorn server:app --port 8000 +``` + +```python title="client.py" hl_lines="20 23 28" --8<-- "docs_src/caching/tutorial003.py" ``` +İkinci bir terminalden `python client.py` komutunu çalıştırın. İlk sonucun taşıdığı ipuçlarını yazdırır: işleyicinin `ttlMs` değeri, eşlemenin `cacheScope`'unun yanında: + +```text +1000 public +``` + +Hikâyenin geri kalanını sunucunun terminali anlatır: uvicorn'un istek log'ları arasında `tools/list served` üç kez görünür. + Dört çağrı, üç getirme. İkinci çağrı taze bir girdi buldu ve sunucuya hiç ulaşmadı; (enjekte edilen) saati TTL'nin ötesine ilerletmek üçüncünün yeniden getirmesine yol açtı; dördüncü `cache_mode="refresh"` dedi. Bu anahtar sözcük argümanı önbellekleme yapan beş fiilde bulunur (`list_tools`, `list_prompts`, `list_resources`, `list_resource_templates`, `read_resource`): * `"use"` (varsayılan) varsa taze bir girdiyi sunar, yoksa getirilen sonucu saklar. @@ -56,7 +70,7 @@ Dört çağrı, üç getirme. İkinci çağrı taze bir girdi buldu ve sunucuya `"use"`'un üzerinde bir kural vardır: **`meta` taşıyan çağrılar her zaman sunucuya ulaşır.** `meta` ayarlanmış bir istek (bir ilerleme token'ı, izleme alanları) ağ üzerinde gerçek bir istek bekler; bu yüzden `cache_mode="use"` altında `"refresh"` gibi ele alınır: önbellek okuması atlanır ve getirilen sonuç yine de önbellekteki girdinin yerine geçer. `"bypass"` ve açık bir `"refresh"` her zamanki gibi davranır. -Önbelleklemeyi tamamen kapatmak için `Client(server, cache=None)` ile oluşturun: her çağrı yeniden bir tur olur ve `cache_mode` hâlâ kabul edilse de hiçbir şey yapmaz. +Önbelleklemeyi tamamen kapatmak için `Client`'ı oluştururken `cache=None` geçirin: her çağrı yeniden bir tur olur ve `cache_mode` hâlâ kabul edilse de hiçbir şey yapmaz. Kapsama da otomatik olarak uyulur: `"private"` girdiler önbelleğin *bölümüne* (partition, aşağıda) göre anahtarlanır, `"public"` olanlar ise daha geniş paylaşıma katılmayı seçebilir. Ayrıca **bildirimler TTL'yi yener**, ama yalnızca tam olarak adlandırdıkları girdiler için: bir `list_changed` bildirimi eşleşen önbellekteki listeyi çıkarır, `resources/updated` ise tam olarak kendi URI'si altında saklanan önbellekteki okumayı çıkarır; ne kadar taze olurlarsa olsunlar. 2026-07-28 bağlantısında bu bildirimler `client.listen(...)` ile açtığınız bir `subscriptions/listen` akışı üzerinden gelir ve çıkarma, izleyiciniz olayı görmeden önce tamamlanır; bunun sayfası **[Abonelikler](subscriptions.md)**. @@ -82,7 +96,7 @@ client = Client("https://api.example.com/mcp", cache=CacheConfig(default_ttl_ms= Bölüm ayrıca `Client`'ın ömrü boyunca sabittir. Bağlantının yetkilendirme bağlamı oturum ortasında değişirse (örneğin farklı bir principal olarak yeniden kimlik doğrulama), önbellek bunu takip etmez; yeni principal için yeni bir `Client` oluşturun. -Önbellek anahtarları ayrıca **sunucunun kimliğini** de taşır: bağlandığınız URL dizesi, varsa `user:pass@` kullanıcı bilgisi çıkarılmış ve bunun dışında bayt bayt aynı hâliyle. Büyük/küçük harf katlama yok, sorgu yeniden sıralama yok, sondaki eğik çizgi temizliği yok. Az normalleştirmek yalnızca paylaşımdan ödün verir, aşırı normalleştirmek ise iki kiracıyı birleştirebilir (`?tenant=a` ve `?tenant=b`); bu yüzden yüzeysel olarak farklı URL'ler girdi paylaşmaz, o kadar. URL olmadığında (süreç içi bir sunucu ya da bir `Transport` örneği) istemci bunun yerine örnek başına rastgele bir kimlik alır; sunucuya ad vermek için `CacheConfig.target_id`'yi ayarlayın (özel bir depoyla bu zorunludur ve oluşturma bunu söyler). Kimlik, anahtar malzemesine girmeden önce sha256 ile özetlenir; dolayısıyla sorgu dizesinde sır taşıyan bir URL depo anahtarlarında asla görünmez. Özet öncesi hâlini siz de loglamayın. +Önbellek anahtarları ayrıca **sunucunun kimliğini** de taşır: bağlandığınız URL dizesi, varsa `user:pass@` kullanıcı bilgisi çıkarılmış ve bunun dışında bayt bayt aynı hâliyle. Büyük/küçük harf katlama yok, sorgu yeniden sıralama yok, sondaki eğik çizgi temizliği yok. Az normalleştirmek yalnızca paylaşımdan ödün verir, aşırı normalleştirmek ise iki kiracıyı birleştirebilir (`?tenant=a` ve `?tenant=b`); bu yüzden yüzeysel olarak farklı URL'ler girdi paylaşmaz, o kadar. URL olmadığında (süreç içi bir sunucu ya da bir `Transport` örneği) istemci bunun yerine örnek başına rastgele bir kimlik alır; sunucuya ad vermek için `CacheConfig.target_id`'yi ayarlayın (özel bir depoyla bu zorunludur ve oluşturma bunu söyler). Kimlik, anahtar malzemesine girmeden önce sha256 ile özetlenir; dolayısıyla sorgu dizesinde sır taşıyan bir URL depo anahtarlarında asla görünmez. Özet öncesi hâlini siz de log'a yazmayın. !!! warning "`share_public` sunucuya tüm filo genelinde güvenir" Varsayılan olarak `"public"` girdiler bile kendi bölümlerinde kalır. `share_public=True`, sunucunun `cacheScope: "public"` olarak işaretlediği girdileri depoyu kullanan **her** bölüme sunar; sunucunun sınıflandırmasına hepsi adına güvenir. Kiracıya özgü veriye (hata ya da kötü niyet sonucu) `"public"` damgası vuran bir sunucu, o zaman bir kiracının yanıtını diğerlerine sızdırır. Bayrak bilerek yalnızca oluşturucu düzeyindedir: çağrı başına `cache_mode` önbelleklemeyi daraltabilir, ama çağrı başına hiçbir şey paylaşımı genişletemez. diff --git a/i18n/tr/pages/client/callbacks.md b/i18n/tr/pages/client/callbacks.md index 8cb234ac..36b86ce1 100644 --- a/i18n/tr/pages/client/callbacks.md +++ b/i18n/tr/pages/client/callbacks.md @@ -1,6 +1,6 @@ --- translation: - sections: [adf3c545b5be46b6, 916cd3ab1c03f461, e9be7a8d0eb0a456, 565890a636288ecf, 6af7e49db9129ec3, 06b0238c174186af, 90c6043be435fcb0] + sections: [adf3c545b5be46b6, 916cd3ab1c03f461, 32ef568335dd95a7, 565890a636288ecf, 6af7e49db9129ec3, 06b0238c174186af, 0abc5ea5cb7ff6b3] tool: 1 --- # İstemci callback'leri {#client-callbacks} @@ -63,7 +63,7 @@ Sizden tek bir `tools/call`, sunucudan geriye tek bir `elicitation/create`, onu `Client(...)` çağrısındaki `mode="legacy"` gerçekten iş yapıyor. Varsayılan olarak `Client(...)` modern protokol yolunu müzakere eder ve o yolda sunucudan istemciye gelen istekler için bir geri kanal (back-channel) yoktur: `ctx.elicit`, callback'iniz daha çalışmadan başarısız olur. Buna aktarım karar vermez; müzakere edilen - protokol karar verir, bellek içinde de bir URL üzerinden de aynı şekilde. İstemcinizin böyle bir isteği + protokol karar verir. İstemcinizin böyle bir isteği yanıtlaması gerektiğinde `mode="legacy"`'yi sabitleyin; bu sayfanın arkasındaki her test bunu yapar. Ayrıntıların tamamı **[Protokol sürümleri](../protocol-versions.md)** sayfasında. @@ -152,4 +152,4 @@ Bunları `Client(...)`'a tıpkı `elicitation_callback` gibi geçirin. * `sampling_callback` ve `list_roots_callback` aynı şekilde çalışır ama kullanım dışı özelliklere hizmet eder; modern sunucular bunun yerine çok turlu istekler (multi-round-trip) kullanır. * `logging_callback` ve `message_handler` bildirimleri alır. Hiçbir şey bildirmezler. -`Client(...)`'ın ilk argümanı bir aktarım nesnesidir. **[İstemci aktarımları](transports.md)** her türünü ele alır. +`Client(...)`'ın ilk argümanı aktarımı seçer. **[İstemci aktarımları](transports.md)** her türünü ele alır. diff --git a/i18n/tr/pages/client/index.md b/i18n/tr/pages/client/index.md index 2cc12da8..47b945d7 100644 --- a/i18n/tr/pages/client/index.md +++ b/i18n/tr/pages/client/index.md @@ -1,6 +1,6 @@ --- translation: - sections: [ebef1e7a0df854f4, 8355cfaf1f76c9d5, 8e79141fc2985342, 46bdb07c7537e8a5, 80ce41579825a6fa, 5f0fa90494de8f65, 83d10514eaa62fa5, 9190555aa39a5d28, 84a4c9d8bf14dddb, 927d71cf40b58c30] + sections: [ebef1e7a0df854f4, 7e16449f66e7dfd6, eeb0682f7d2a1079, 5713f0196a34e6e7, 0e844597859e4248, 3a97d9195ddcc92e, 1da08c483e59c141, 84702cc6e0a1fd42, 8dee7a31c86ffc5c, 83a5bce168ef23d7] tool: 1 --- # İstemci {#the-client} @@ -11,13 +11,23 @@ Tek bir yaşam döngüsü olan tek bir nesnedir: oluşturun, `async with` bloğu ## İlk istemciniz {#your-first-client} -```python title="client.py" hl_lines="14-18" +Bir istemcinin konuşacağı bir sunucuya ihtiyacı vardır. Bu sayfadaki her örneğin bağlandığı sunucu aşağıdaki Bookshop. Onu `server.py` olarak kaydedin ve HTTP üzerinden çalışır durumda bırakın: + +```python title="server.py" --8<-- "docs_src/client/tutorial001.py" ``` -Üstteki sunucu yalnızca bağlanacak bir şeyiniz olsun diye orada. İstemci, vurgulanan beş satırdan ibaret. +```console +uv run mcp run server.py --transport streamable-http +``` -* `Client(mcp)` çağrısına **sunucu nesnesinin kendisi** verilir. Bu, bellek içi aktarımdır: alt süreç yok, port yok, HTTP yok. Bu sayfadaki her örnek ve yazdığınız her test böyle bağlanır. +Bu, sunucuyu `http://localhost:8000/mcp` adresinde sunar. İstemci ayrı bir programdır. Onu `client.py` olarak kaydedin ve ikinci bir terminalde `python client.py` komutunu çalıştırın: + +```python title="client.py" hl_lines="7-11" +--8<-- "docs_src/client/tutorial001_client.py" +``` + +* `Client("http://localhost:8000/mcp")` çağrısına bir **URL** verilir; bu yüzden az önce başlattığınız sunucuya Streamable HTTP üzerinden bağlanır. * `async with` **yaşam döngüsüdür**. Bloğa girdiğinizde bağlantı kurulur ve anlaşma yapılır; çıktığınızda bağlantı kesilir. `connect()` / `close()` çifti yoktur ve blok bittikten sonra bir `Client` yeniden kullanılamaz. * Bloğun içinde bağlantı bilgileri düz özellikler olarak zaten hazırdır. @@ -25,10 +35,10 @@ Tek bir yaşam döngüsü olan tek bir nesnedir: oluşturun, `async with` bloğu `Client` tek bir konumsal argüman alır ve aktarımı onun türünden çözümler: -* Bir `MCPServer` (veya düşük seviyeli `Server`) örneği: **süreç içinde** bağlanır. -* Bir URL dizesi (`Client("http://localhost:8000/mcp")`): Streamable HTTP, yani üretim yolu. -* Bir `StdioServerParameters`: **alt süreç** olarak başlatılacak komut; onunla stdin ve stdout'u üzerinden konuşulur. +* Bir URL dizesi (`Client("http://localhost:8000/mcp")`): Streamable HTTP, dağıtımda kullandığınız aktarım. +* Bir `StdioServerParameters`: yerel bir **alt süreç** olarak başlatılacak komut; onunla stdin ve stdout'u üzerinden konuşulur. * Bir **aktarım**: `async with ... as (read, write)` ile kullanabileceğiniz herhangi bir şey; örneğin kendi HTTP istemcinizi saran `streamable_http_client(url, http_client=...)`. +* Bir `MCPServer` (veya düşük seviyeli `Server`) örneği: **süreç içinde** bağlanır; alt süreç yok, port yok. Bu seçenek testler içindir ve **[Test etme](../get-started/testing.md)** sayfası onun üzerine kurulur. Bu sayfadaki geri kalan her şey dördünde de aynıdır. Başlıklar, alt süreçler, zaman aşımları ve `Transport` protokolünün kendi sayfası var: **[İstemci aktarımları](transports.md)**. @@ -49,11 +59,11 @@ Hiç protokol sürümü seçmediniz. Varsayılan olarak `Client` sunucuyu yoklar ## Araçları listeleme {#listing-tools} -```python title="client.py" hl_lines="15-20" +```python title="client.py" hl_lines="8-13" --8<-- "docs_src/client/tutorial002.py" ``` -`list_tools()` bir `ListToolsResult` döndürür; araçlar `.tools` içindedir. Her biri, bir host'un modele vereceği eksiksiz tanımdır: +`list_tools()` bir `ListToolsResult` döndürür; araçlar `.tools` içindedir. Her biri, bir host'un modele vereceği eksiksiz tanımdır. İşte ilki: ```python tool.name # 'search_books' @@ -77,6 +87,8 @@ tool.description # 'Search the catalog by title or author.' Bu şema, bir arayüzün argüman formu oluşturması için gereken her şeydir; bir modelin geçerli argümanlar üretmesi için gereken her şey de odur. +İkinci araç olan `lookup_book`, `title=` olmadan kaydedildi; bu yüzden `tool.title` değeri `None`. + !!! tip `title` isteğe bağlıdır; bu yüzden araçları bir insana gösteren arayüzün seçim yapması gerekir: varsa `title`, yoksa `name`. `from mcp.shared.metadata_utils import get_display_name` tam olarak bunu yapar; @@ -86,7 +98,7 @@ Bu şema, bir arayüzün argüman formu oluşturması için gereken her şeydir; `call_tool(name, arguments)` aracı çalıştırır ve size bir `CallToolResult` geri verir. -```python title="client.py" hl_lines="27-34" +```python title="client.py" hl_lines="9-16" --8<-- "docs_src/client/tutorial003.py" ``` @@ -142,7 +154,7 @@ Tek dönüş değeri, okunacak üç şey. Her birinin tüketicisi farklı. Kaynak fiilleri çift gelir: listelemenin iki yolu, okumanın tek yolu. -```python title="client.py" hl_lines="22-31" +```python title="client.py" hl_lines="9-18" --8<-- "docs_src/client/tutorial004.py" ``` @@ -156,7 +168,7 @@ Bir istemciye bir kaynağın ne zaman değiştiği de bildirilebilir. 2025 nesli ## Prompt'lar {#prompts} -```python title="client.py" hl_lines="15-20" +```python title="client.py" hl_lines="8-13" --8<-- "docs_src/client/tutorial005.py" ``` @@ -181,7 +193,7 @@ Host bu mesajları doğrudan modele verir. Özelliğin tamamı bu. Tamamlama işleyicisi olan bir sunucu, kullanıcı yazdıkça prompt ve kaynak şablonu argümanlarını otomatik tamamlayabilir. -```python title="client.py" hl_lines="27-31" +```python title="client.py" hl_lines="9-13" --8<-- "docs_src/client/tutorial006.py" ``` @@ -194,21 +206,21 @@ Yanıt `result.completion.values` içindedir. `"p"` yazın, sunucu `['poetry']` Her `list_*` yöntemi bir `cursor=` anahtar sözcüğü alır ve her sonuç bir `next_cursor` taşır. `next_cursor` `None` olduğunda her şeyi almışsınız demektir. -```python title="client.py" hl_lines="22-30" +```python title="client.py" hl_lines="7-15" --8<-- "docs_src/client/tutorial007.py" ``` -Bu döngü her sunucuya karşı doğrudur. `MCPServer` her şeyi tek sayfada döndürür; bu yüzden `next_cursor` `None` olur ve döngü bir kez çalışır. Çoğu kodun bunu hiç yazmamasının nedeni budur. Gerçekten sayfalayan sunucular ve imleçlerin uyduğu kurallar **[Sayfalama](../advanced/pagination.md)** sayfasında. +`list_all_tools` her sunucuya karşı doğrudur. `MCPServer` her şeyi tek sayfada döndürür; bu yüzden `next_cursor` `None` olur ve döngü bir kez çalışır. Çoğu kodun bunu hiç yazmamasının nedeni budur. Gerçekten sayfalayan sunucular ve imleçlerin uyduğu kurallar **[Sayfalama](../advanced/pagination.md)** sayfasında. ## Testlerde {#in-tests} -Süreç ve port olmadan `Client(mcp)`, sunucunuz için zaten bir test düzeneğidir. +Bu sayfadaki her `client.py`, `server.py` dosyasına HTTP üzerinden ulaştı. Bir testte ağı atlar ve `Client`'a sunucu nesnesinin kendisini verirsiniz: `from server import mcp`, ardından `Client(mcp)`. Süreç yok, port yok; yukarıdaki her yöntem aynı şekilde çalışır. -Bunun için yapılmış tek bir kurucu bayrağı var: `Client(mcp, raise_exceptions=True)`. Yalnızca bellek içi bağlantılarda etkisi olur; onu açıklayan ve bütün kalıbı onun etrafında kuran sayfa ise **[Test etme](../get-started/testing.md)**. +Bunun için yapılmış tek bir kurucu bayrağı var: `Client(mcp, raise_exceptions=True)`. Yalnızca süreç içi bağlantılarda etkisi olur; onu açıklayan ve bütün kalıbı onun etrafında kuran sayfa ise **[Test etme](../get-started/testing.md)**. ## Özet {#recap} -* `Client(x)` bir sunucu nesnesine bellek içinden, bir URL dizesine Streamable HTTP üzerinden, geri kalan her şeye de bir aktarım aracılığıyla bağlanır. +* `Client(x)` bir URL dizesine Streamable HTTP üzerinden bağlanır, bir `StdioServerParameters` için alt süreç başlatır, bir aktarıma doğrudan girer ve testlerde sunucu nesnesinin kendisini alır. * `async with` yaşam döngüsünün tamamıdır. İçinde `server_capabilities` ve `protocol_version` zaten doludur; sunucu sağladığında `server_info` ve `instructions` da öyle. * `list_tools()` size her aracın `name`, `title`, `description` ve `input_schema` değerlerini verir. * `call_tool()` model için `content`, kodunuz için `structured_content` ve `is_error` döndürür. İstisna fırlatan bir araç istisna değil, sonuçtur. diff --git a/i18n/tr/pages/client/oauth-clients.md b/i18n/tr/pages/client/oauth-clients.md index 640f85a3..8619f430 100644 --- a/i18n/tr/pages/client/oauth-clients.md +++ b/i18n/tr/pages/client/oauth-clients.md @@ -1,6 +1,6 @@ --- translation: - sections: [c6899d3892bd9fa0, 79372cff3cc48a88, 63878d29e87c3e73, 13175843d3588af4, e7e2b9fd516f77de, 758f06399b513c1f, a05d7278487d610b] + sections: [c6899d3892bd9fa0, 79372cff3cc48a88, c2dae1ebe2ebd543, 13175843d3588af4, df06056fb16b3846, 758f06399b513c1f, a05d7278487d610b] tool: 1 --- # OAuth istemcileri {#oauth-clients} @@ -82,20 +82,22 @@ Gerçek bir istemci `input()` çağırmak yerine yönlendirme URI'si üzerinde k `Client` ilk kez bir istek gönderdiğinde sunucu `401` yanıtını verir. Sağlayıcı devralır: -1. **Keşif.** `WWW-Authenticate` başlığını okur, sunucunun Protected Resource Metadata belgesini `/.well-known/oauth-protected-resource` adresinden alır, bu kaynağı hangi yetkilendirme sunucusunun koruduğunu öğrenir ve *o* sunucunun metadatasını alır. +1. **Keşif.** `WWW-Authenticate` başlığını okur, sunucunun Protected Resource Metadata belgesini `/.well-known/oauth-protected-resource` adresinden alır, bu kaynağı hangi yetkilendirme sunucusunun koruduğunu öğrenir ve *o* sunucunun metadatasını alır. (Kaynak metadatası yayımlamayan daha eski bir sunucudan bunun yerine kendi kökeninde yetkilendirme sunucusu metadatası istenir.) Her iki durumda da metadata, `issuer` alanında hangi sunucu için alındıysa o sunucuyu belirtmelidir; başka her şey reddedilir. 2. **Kayıt.** Depoda bir şey yok mu? `OAuthClientMetadata`'nızla sizi dinamik olarak kaydeder ve sonucu saklar. 3. **Yetkilendirme.** PKCE çiftini ve bir `state` üretir, yetkilendirme URL'sini oluşturur, `redirect_handler`'ınızı await eder, ardından kod için `callback_handler`'ınızı await eder. 4. **Değişim.** Kodu bir `OAuthToken` ile takas eder, onu saklar ve özgün isteğinizi `Authorization: Bearer ...` ile yeniden gönderir. Bundan sonra sessizdir. Token'lar depodan gelir, süresi dolmuş bir erişim token'ı yenileme token'ıyla yenilenir ve ancak bunların hiçbiri işe yaramadığında akışı yeniden çalıştırır. +Bu isteklerin hepsi için tek bir aktarım kuralı geçerlidir: içinde çalıştıkları MCP isteği gibi, bir yönlendirmeyi yalnızca aynı kökende kalıyor ve metodu koruyorsa izlerler (örneğin sondaki eğik çizgi için bir 307/308); başka her yönlendirmeyi o URL yanıt vermiyormuş gibi ele alırlar. + Bunların hiçbirini siz yazmadınız. Geriye iki anahtar sözcük argümanı kalır (`client_metadata_url` ve `validate_resource_url`) ve bu dosyanın ikisine de ihtiyacı yoktur. Bilmeye değer olanı `client_metadata_url`'dir; aşağıda kendi bölümü var. ### Deneyin {#try-it} -Bu belgelerdeki örneklerin çoğunu bellek içi bir `Client(server)` ile sınayabilirsiniz. Bunu değil: akışın bütün amacı bir HTTP `401`'idir ve bellek içi bir istemci ile sunucusu arasında HTTP yoktur. +Testlerinizin kullandığı bellek içi `Client(server)` burada işe yaramaz: akışın bütün amacı bir HTTP `401`'idir ve bellek içi bir istemci ile sunucusu arasında HTTP yoktur. -Depo canlı sürümü içerir. `examples/servers/simple-auth/` bağımsız bir yetkilendirme sunucusu ile korumalı bir MCP sunucusu çalıştırır; `examples/clients/simple-auth-client/` ise bu sayfadaki istemcinin küçük bir CLI'a dönüşmüş hâlidir. README'sinde iki komut var: sunucuları başlatın, istemciyi onlara karşı çalıştırın ve dört adımın geçişini izleyin. +Depo canlı sürümü içerir. `examples/servers/simple-auth/` bağımsız bir yetkilendirme sunucusu ile korumalı bir MCP sunucusu çalıştırır; `examples/clients/simple-auth-client/` ise bu sayfadaki istemcinin küçük bir CLI aracına dönüşmüş hâlidir. README'sinde iki komut var: sunucuları başlatın, istemciyi onlara karşı çalıştırın ve dört adımın geçişini izleyin. ## Client ID Metadata Documents {#client-id-metadata-documents} @@ -111,13 +113,14 @@ Bir gece görevi, bir CI adımı, başka bir servis. Tarayıcı yok, "allow" dü `ClientCredentialsOAuthProvider` aynı `httpx2.Auth`'tur, insan hariç: -```python title="client.py" hl_lines="4 27-33" +```python title="client.py" hl_lines="4 27-34" --8<-- "docs_src/oauth_clients/tutorial002.py" ``` Neler değişti: * `OAuthClientMetadata` yok, işleyiciler yok. `client_id` ve `client_secret` geçirirsiniz; sağlayıcı bunların etrafında asgari bir `client_credentials` kaydı oluşturur ve dinamik kaydı tamamen atlar. +* `issuer`, bu kimlik bilgilerini veren yetkilendirme sunucusunu belirtir; onun `/.well-known/oauth-authorization-server` belgesinin döndürdüğü `issuer` değerini kullanın. Keşif yine yukarıdaki gibi çalışır, ancak token istekleri yalnızca *o* yayıncıya ait metadatadan oluşturulur; MCP sunucusu başka bir yeri gösteriyorsa akış bunun yerine bir `OAuthFlowError` ile durur. Bu parametreyi atlamak kullanım dışı bırakıldı ve 3.0 sürümünde zorunlu hâle gelir (bkz. **[Kullanım dışı özellikler](../deprecated.md#deprecated-sdk-helpers)**); o zamana kadar sağlayıcı uyarı verir ve keşif hangi yetkilendirme sunucusunu bulursa onu kullanır. * `scope`, boşlukla ayrılmış bir dizedir; OAuth'un iletilen verideki biçimi budur. * Bundan sonraki her şey aynıdır: aynı `TokenStorage`, aynı `httpx2.AsyncClient(auth=...)`, aynı `streamable_http_client`. @@ -130,7 +133,7 @@ Varsayılan olarak sır, token isteğinde HTTP Basic kimlik doğrulaması olarak `mcp.client.auth.extensions.client_credentials` içinde bir sağlayıcı daha var: paylaşılan bir sır yerine JWT ile kimlik doğrulayan istemciler için **`PrivateKeyJWTOAuthProvider`** (`private_key_jwt`; anahtar çifti ve iş yükü kimliği türü). Aynı kalıbı izler: - bir tane oluşturun, `auth=`'a koyun. Aynı modül, onun assertion'ını oluşturan iki yardımcıyı da + bir tane oluşturun (aynı isteğe bağlı `issuer` parametresini alır), `auth=`'a koyun. Aynı modül, onun assertion'ını oluşturan iki yardımcıyı da sunar: `SignedJWTParameters` ve `static_assertion_provider`. İnsansız bir durum daha var: istemci, hangi MCP sunucularına erişebileceğine kullanıcının değil kimlik sağlayıcısının karar verdiği bir kuruluşa aittir. Bu, kendi güven modeli ve kendi sayfası olan farklı bir yetkilendirme türüdür: **[Kimlik beyanı](identity-assertion.md)**. diff --git a/i18n/tr/pages/client/session-groups.md b/i18n/tr/pages/client/session-groups.md index 25e965c4..0499620d 100644 --- a/i18n/tr/pages/client/session-groups.md +++ b/i18n/tr/pages/client/session-groups.md @@ -1,6 +1,6 @@ --- translation: - sections: [09c857a25a9dc37a, 43bc6a76a243a50e, 0a716022a88768df, 4b7f78042bfcfff7, c112662e61b03315, 58974ba1f489a8b4, d18adbdbb835ea73] + sections: [09c857a25a9dc37a, 43bc6a76a243a50e, 0a716022a88768df, 4b7f78042bfcfff7, c112662e61b03315, 58974ba1f489a8b4, ed4d17e894864056] tool: 1 --- # Oturum grupları {#session-groups} @@ -78,7 +78,7 @@ Elinizde zaten bağlı bir `ClientSession` varsa (`Client.session` bunlardan bir ## Özet {#recap} * `ClientSessionGroup` birçok sunucu bağlantısını tutar ve bunların araçlarını, kaynaklarını ve prompt'larını birer `dict`'te birleştirir. -* Her sunucu için `connect_to_server(params)`. Aktarım parametreleri alır; bir `Client`'ın aldığı sunucu nesnesini ya da URL'yi asla almaz. +* Her sunucu için `connect_to_server(params)`. Aktarım parametreleri alır; bir `Client`'ın aldığı URL'yi ya da `Transport`'u asla almaz. * `group.call_tool(name, arguments)` çağrıyı sizin yerinize sahibi olan sunucuya yönlendirir. * Adlar grubun tamamında benzersiz olmalıdır; `search` aracı olan iki sunucu kendi hâllerine bırakılırsa bir arada bulunamaz. * `component_name_hook=` kaydedilen her adı yeniden yazar. Sözlük anahtarı değişir, ağ üzerindeki ad değişmez. diff --git a/i18n/tr/pages/client/transports.md b/i18n/tr/pages/client/transports.md index 552ede30..bbf104cc 100644 --- a/i18n/tr/pages/client/transports.md +++ b/i18n/tr/pages/client/transports.md @@ -1,6 +1,6 @@ --- translation: - sections: [9cac816674181eb0, 0700f337babcd4dd, 2bde0dd58cdf00f5, 40b4916d82eaf1d4, 3d0832f39b0d7059, dfa4446556badef0, 5bd93be2ab2ecb9c] + sections: [9cac816674181eb0, 7c157764133fea1f, 40b4916d82eaf1d4, 10d151f2cc75317f, 3d0832f39b0d7059, 92742ba36533633d, 0aeca6145e7bd302] tool: 1 --- # İstemci aktarımları {#client-transports} @@ -11,30 +11,15 @@ Aktarımı hiçbir zaman ayrıca yapılandırmazsınız. `Client` tek bir konums Her birinin *sunucu* tarafı (`mcp.run()`'ın ne yaptığı ve neyi dağıttığınız) **[Sunucunuzu çalıştırma](../run/index.md)** sayfasında. -## Bellek içinde {#in-memory} - -Sunucu nesnesinin kendisini geçirin: - -```python title="client.py" hl_lines="14" ---8<-- "docs_src/client_transports/tutorial001.py" -``` - -Alt süreç yok, port yok, ağ üzerinde tek bir bayt yok. İstemci ve sunucu aynı süreçteki iki nesnedir; yine de çağrı gerçek protokol katmanından geçer: `search_books`, HTTP üzerinden nasıl olacaksa tam olarak öyle listelenir, doğrulanır ve çağrılır. - -Bu, onu aynı anda iki şey yapar: - -* **Bir test düzeneği.** Bu belgelerdeki her örnek bu şekilde çalıştırılır ve **[Test etme](../get-started/testing.md)** sayfası tüm deseni bunun üzerine kurar. -* **Bir gömme API'si.** Sunucuyu oluşturan bir uygulamanın, araçlarını çağırmak için ağ üzerinden bir sıçrama yapmasına gerek yoktur. - ## Streamable HTTP {#streamable-http} -Bir URL dizesi geçirin; arkasına dağıtım yaptığınız aktarım olan **Streamable HTTP**'yi elde edersiniz: +Bir URL dizesi geçirin; arkasına dağıtım yaptığınız ve ilk başvurmanız gereken aktarım olan **Streamable HTTP**'yi elde edersiniz: ```python title="client.py" hl_lines="5" --8<-- "docs_src/client_transports/tutorial002.py" ``` -Üretim istemcisinin tamamı bu. `Client`, URL'yi sizin için `streamable_http_client(...)` ile sarar; bunu da MCP'nin gerektirdiği şekilde yapılandırılmış bir `httpx2.AsyncClient` üzerine kurar: `follow_redirects=True`, connect/write/pool için 30 saniyelik zaman aşımı ve sunucu bir yanıt akışını açık tutabileceği için 300 saniyelik okuma zaman aşımı. +Üretim istemcisinin tamamı bu. `Client`, URL'yi sizin için `streamable_http_client(...)` ile sarar; bunu da MCP'nin gerektirdiği şekilde yapılandırılmış bir `httpx2.AsyncClient` üzerine kurar: connect/write/pool için 30 saniyelik zaman aşımı ve sunucu bir yanıt akışını açık tutabileceği için 300 saniyelik okuma zaman aşımı. !!! check Oluşturduğunuz bir `Client` bağlı **değildir**. Oluşturma yalnızca aktarımı seçer; @@ -50,7 +35,7 @@ Bir URL dizesi geçirin; arkasına dağıtım yaptığınız aktarım olan **Str Bir `Authorization` başlığına, bir çereze, bir vekil sunucuya, mTLS'e ya da farklı bir zaman aşımına ihtiyaç duyduğunuz anda `httpx2.AsyncClient`'ı kendiniz oluşturun ve `streamable_http_client`'a verin: -```python title="client.py" hl_lines="8-14" +```python title="client.py" hl_lines="8-13" --8<-- "docs_src/client_transports/tutorial003.py" ``` @@ -79,9 +64,30 @@ ortam değişkenlerini ayarlayın ya da `httpx2.AsyncClient`'ınıza açıkça b !!! info `httpx2`, tanıdık `httpx` API'sini korur; yani `httpx`'i biliyorsanız kimlik doğrulama, vekil sunucular, olay kancaları, yeniden denemeler ve bağlantı sınırlarının burada nasıl yapılacağını zaten biliyorsunuz. SDK üzerine hiçbir şey eklemez, - hiçbir şeyi de eksiltmez. OAuth'un takıldığı yer de burası: + [yönlendirme işleme](#redirects) dışında hiçbir şeyi de eksiltmez. OAuth'un takıldığı yer de burası: `httpx2.AsyncClient(auth=OAuthClientProvider(...))`. Bu akışın tamamı **[OAuth istemcileri](oauth-clients.md)** sayfasında. +### Yönlendirmeler {#redirects} + +Aktarım, verdiğiniz URL'ye ve yalnızca o kökene (origin) bağlanır. + +* Aynı şema, ana bilgisayar ve portta kalan bir `307`/`308` yönlendirmesi izlenir; aynı ana bilgisayar üzerindeki `http://` → `https://` yönlendirmesi de öyle. Bu, alışıldık `/mcp` → `/mcp/` sondaki eğik çizgi yönlendirmesini kapsar. +* Başka herhangi bir yere yapılan yönlendirme **izlenmez**. Çağrı şu hatayla başarısız olur: + + ```text + MCPError: Redirect to https://other.example.com/mcp not followed; use that URL as the endpoint if it is the intended server + ``` + + O URL kastettiğiniz sunucuysa yapılandırmanıza yazın. Değilse sunucu ya da önündeki bir vekil sunucu yanlış yapılandırılmıştır. + +Bu, geçirdiğiniz her `httpx2.AsyncClient` için geçerlidir: MCP isteklerinde `follow_redirects` ayarına hiçbir yönde bakılmaz. SDK'nın OAuth sağlayıcıları da aynı kuralı kendi isteklerine uygular. + +!!! tip + `Redirect to http://… not followed: it would downgrade this HTTPS endpoint to plain HTTP` iletisi, + sunucunun haberdar olmadığı, TLS sonlandıran bir vekil sunucunun arkasında durduğu ve `http://` yönlendirmeleri + verdiği anlamına gelir. Bu, sunucu tarafında düzeltilir (**[Dağıtım ve ölçekleme](../run/deploy.md#behind-a-tls-terminating-proxy)**) + ya da iletinin önerdiği tam `https://…/` URL'si kullanılarak. + ## stdio {#stdio} Bir **stdio** sunucusu bir alt süreçtir. İstemci onu başlatır, stdin'ine JSON-RPC yazar ve stdout'undan JSON-RPC okur. Bir masaüstü host'un makinenizde bir sunucuyu çalıştırma biçimi budur: bir host, bu kod artı bir kullanıcı arayüzü*dür* ve **[Gerçek bir host'a bağlanma](../get-started/real-host.md)**, aynı ilişkinin host'un tarafından, bir yapılandırma dosyası olarak görülen halidir. @@ -104,6 +110,18 @@ Alt sürecin stderr'i sizinkine gider. Başka bir yere göndermek için aktarım Bir API anahtarına ihtiyaç duyan bir sunucu onu orada bulamaz. `env=` ile açıkça geçirin; bu değişkenler izin listesinin üstüne birleştirilir. Yukarıda `BOOKSHOP_API_KEY`'in yaptığı budur. +## Bellek içinde {#in-memory} + +Bir testte dağıtılacak da başlatılacak da bir şey yoktur. Sunucu nesnesinin kendisini geçirin: + +```python hl_lines="14" +--8<-- "docs_src/client_transports/tutorial001.py" +``` + +Alt süreç yok, port yok, ağ üzerinde tek bir bayt yok. İstemci ve sunucu aynı süreçteki iki nesnedir; yine de çağrı gerçek protokol katmanından geçer: `search_books`, HTTP üzerinden nasıl olacaksa tam olarak öyle listelenir, doğrulanır ve çağrılır. **[Test etme](../get-started/testing.md)** sayfası tüm deseni bunun üzerine kurar. + +Aynı biçim bir gömme API'si olarak da iş görür: sunucuyu kendisi oluşturan bir uygulama, araçlarını ağ üzerinden bir sıçrama olmadan çağırabilir. + ## SSE {#sse} `mcp.client.sse` içindeki `sse_client(url)`, Streamable HTTP'nin yerini aldığı HTTP aktarımıdır. Hâlâ onu konuşan bir sunucuyla konuşmak için aynı şekilde sarın, `Client(sse_client("http://localhost:8000/sse"))`, ve üzerine yeni hiçbir şey kurmayın. @@ -112,15 +130,16 @@ Alt sürecin stderr'i sizinkine gider. Başka bir yere göndermek için aktarım `Client` için yukarıdakilerin hepsi aynı şeydir. -Bir **aktarım**, `(read, write)` mesaj akışı çifti veren herhangi bir asenkron bağlam yöneticisidir: resmi olarak `mcp.client` içindeki `Transport` protokolü. `Client`, argümanını türüne göre çözümler: bir sunucu nesnesi süreç içinde bağlanır, bir `str` `streamable_http_client(url)` olur, bir `StdioServerParameters` `stdio_client(params)` olur ve geri kalan her şeye doğrudan bir aktarım olarak girilir. `stdio_client(...)`, `streamable_http_client(...)` ve `sse_client(...)`'in hepsinin aynı yuvaya oturmasının ve kendinizinkini yazabilmenizin nedeni bu son kuraldır. +Bir **aktarım**, `(read, write)` mesaj akışı çifti veren herhangi bir asenkron bağlam yöneticisidir: resmi olarak `mcp.client` içindeki `Transport` protokolü. `Client`, argümanını türüne göre çözümler: bir `str` `streamable_http_client(url)` olur, bir `StdioServerParameters` `stdio_client(params)` olur, bir sunucu nesnesi süreç içinde bağlanır ve geri kalan her şeye doğrudan bir aktarım olarak girilir. `stdio_client(...)`, `streamable_http_client(...)` ve `sse_client(...)`'in hepsinin aynı yuvaya oturmasının ve kendinizinkini yazabilmenizin nedeni bu son kuraldır. ## Özet {#recap} -* `Client(mcp)` (sunucu nesnesi) bellek içinde bağlanır. Testler ve gömme için kullanın. * `Client("http://.../mcp")` (bir URL), üretim aktarımı olan Streamable HTTP üzerinden bağlanır. * Başlıklar, kimlik doğrulama, vekil sunucular ve zaman aşımları, `streamable_http_client(url, http_client=...)`'a geçirdiğiniz bir `httpx2.AsyncClient` üzerinde yer alır. `headers=` anahtar sözcüğü yoktur. +* Yönlendirmeler yalnızca URL'nin kendi kökeni içinde (sondaki eğik çizgi için `307`/`308`) ve aynı ana bilgisayarda `http`→`https` için izlenir. Geri kalan her şey `Redirect to … not followed` hatasıyla başarısız olur; nihai URL'yi yapılandırın. * stdio, `Client(StdioServerParameters(...))` demektir. Onu `stdio_client(...)` ile yalnızca alt sürecin stderr'ini başka yere yönlendirmek için kendiniz sarın. * Alt süreç sizinkini değil, izin listesine göre oluşturulmuş bir ortam alır; `env=` buna ekleme yapar. +* `Client(mcp)` (sunucu nesnesi) bellek içinde bağlanır. Testlerde ya da bir sunucuyu onu oluşturan uygulamaya gömmek için kullanın. * Bir aktarım, `async with x as (read, write)` yapabildiğiniz herhangi bir şeydir. `Client`, sunucu nesnesi, URL ya da `StdioServerParameters` olmayan her şeyi doğrudan bu protokole verir. * Bir `Client` oluşturmak aktarımı seçer. Onu `async with` açar. diff --git a/i18n/tr/pages/deprecated.md b/i18n/tr/pages/deprecated.md index af116483..945cdba6 100644 --- a/i18n/tr/pages/deprecated.md +++ b/i18n/tr/pages/deprecated.md @@ -1,11 +1,11 @@ --- translation: - sections: [490237e61c3a7a44, 01262a123ad9501d, 429db5b574a2ac08, e2d0d273fbd2d74b, 64ab0331e868f3d4, 6c8878ce2d1f6d56, 4068f23e371bf0b3, eaef75b8725bc931] + sections: [4c1dea378b2b1bf7, 01262a123ad9501d, 429db5b574a2ac08, e2d0d273fbd2d74b, 64ab0331e868f3d4, 6c8878ce2d1f6d56, c3d1099701156881, e185a1b8e53669f6] tool: 1 --- # Kullanım dışı özellikler {#deprecated-features} -2026-07-28 spesifikasyonu beş şeyi emekliye ayırıyor. SDK hâlâ hepsini uygular ve artık her biri bir **kullanım dışı bırakma uyarısı** taşır. Bir SDK yardımcısı ise kendi gerekçesiyle kullanım dışı bırakıldı ve [sayfanın sonunda](#deprecated-sdk-helpers) listeleniyor. +2026-07-28 spesifikasyonu beş şeyi emekliye ayırıyor. SDK hâlâ hepsini uygular ve artık her biri bir **kullanım dışı bırakma uyarısı** taşır. SDK düzeyindeki birkaç kullanım dışı bırakma ise kendi gerekçesine dayanır ve [sayfanın sonunda](#deprecated-sdk-helpers) listeleniyor. Aşağıdaki tablo kullanım dışı bırakılan her özelliği, neden gittiğini ve yerine neyin üzerine inşa etmeniz gerektiğini gösterir. @@ -136,11 +136,13 @@ API'nin tamamı bu. Yöntem başına bir anahtar yok, zaten istemezsiniz de: tek ## Kullanım dışı SDK yardımcıları {#deprecated-sdk-helpers} -Bunlar spesifikasyon değişikliği değil, yalnızca daha iyi bir alternatifi olan SDK iç ayrıntılarıdır. Aynı `MCPDeprecationWarning` ile uyarırlar ve 3.0'da kaldırılacaklar. +Bunlar spesifikasyon değişikliği değil, yalnızca daha iyi bir alternatifi olan SDK kullanım biçimleridir. Aynı `MCPDeprecationWarning` ile uyarırlar ve 3.0 eski biçimi kaldırır. | Kullanım dışı | Bunun yerine ne yaparsınız | |---|---| | `FuncMetadata.call_fn_with_arg_validation()` | `FuncMetadata.validate_arguments()`, ardından `FuncMetadata.call_fn()`. Bunu yalnızca `FuncMetadata`'yı doğrudan kullanan kod (örneğin özel bir `Tool` alt sınıfı) çağırırdı. | +| `validate_token_resource=` olmadan `AuthSettings(resource_server_url=...)` | Onu ayarlayın: `True`, doğrulayıcınızın `resource_server_url` için düzenlendiğini bildirmediği bearer token'ları sunucunun reddetmesini sağlar; `False`, token'ın audience değerini doğrulayıcınızın kendisinin denetlediğini söyler (bkz. **[Yetkilendirme](run/authorization.md#a-token-verifier)**). Ayarlanmadığında `False` gibi davranır; 3.0, `resource_server_url` ayarlı olduğunda `True`'yu varsayılan yapar. | +| `issuer=` olmadan `ClientCredentialsOAuthProvider(...)` veya `PrivateKeyJWTOAuthProvider(...)` | Kimlik bilgilerini düzenleyen yetkilendirme sunucusunu belirten `issuer=` geçirin (bkz. **[OAuth istemcileri yazma](client/oauth-clients.md#machine-to-machine)**). Onsuz, kimlik bilgilerini hangi yetkilendirme sunucusunun alacağına MCP sunucusu karar verir; 3.0 bu anahtar kelimeyi zorunlu yapar. | ## Özet {#recap} @@ -149,7 +151,7 @@ Bunlar spesifikasyon değişikliği değil, yalnızca daha iyi bir alternatifi o * Kullanım dışı bırakma tavsiye niteliğindedir: iletilen veride değişiklik yok, her şey 2026 öncesi oturumlarda çalışmaya devam eder ve görünür bir `MCPDeprecationWarning` alırsınız (bir `UserWarning`, dolayısıyla varsayılan olarak açık). * Örnekleme ve kök dizinler ayrıca, 2026-07-28 oturumunda bulunmayan bir geri kanala ihtiyaç duyar. Modern bir bağlantıda önce uyarır, sonra istisna fırlatırlar. * `warnings.filterwarnings("ignore", category=MCPDeprecationWarning)` tüm kategoriyi susturur; pytest'te `"error::mcp.MCPDeprecationWarning"` bunu bir test hatasına dönüştürür. -* Bir SDK yardımcısı, `FuncMetadata.call_fn_with_arg_validation()`, 3.0'da kaldırılmak üzere ayrıca kullanım dışı bırakıldı. +* [SDK düzeyindeki kullanım dışı bırakmalar](#deprecated-sdk-helpers) da aynı kurala uyar: şimdi uyarırlar, 3.0 ise eski biçimi kaldırır. * Yeni kod bunların hiçbiri üzerine kurulmamalıdır. Bu belgelerdeki diğer tüm sayfalar güncel API'yi anlatır. diff --git a/i18n/tr/pages/get-started/first-steps.md b/i18n/tr/pages/get-started/first-steps.md index e7e9ec74..f52e64fc 100644 --- a/i18n/tr/pages/get-started/first-steps.md +++ b/i18n/tr/pages/get-started/first-steps.md @@ -1,6 +1,6 @@ --- translation: - sections: [0d6c05bcbf836bf3, 59a7b14eeefc68c1, 7114d8d6daba203f, e8bbb56a98ba7bc9, 5138010f6159901c, f78da7c7c363d4c6, 220a939cab348686] + sections: [0d6c05bcbf836bf3, 9a78b5f6b44b18ab, 7114d8d6daba203f, e8bbb56a98ba7bc9, bfd2fd1153e71dac, 1615a994ef071fdd, 65c599fae991f245] tool: 1 --- # İlk adımlar {#first-steps} @@ -17,7 +17,7 @@ Bundan sonraki her sayfada göreceğiniz üç sözcük: * **İstemci**, host'un içinde yaşar ve MCP konuşur. Host, bağlandığı her sunucu için bir istemci çalıştırır. * **Sunucu**, bu SDK ile sizin oluşturduğunuz şeydir. İstemcilere bir şeyler sunar. Modelle hiçbir zaman doğrudan konuşmaz. -Sunucuyu siz yazarsınız. Host'lar başkasının ürünüdür. SDK size bir de `Client` verir. Onu sunucularınızı test etmek için kullanırsınız; bu sayfanın ilerisinde karşınıza çıkar. +Sunucuyu siz yazarsınız. Host'lar başkasının ürünüdür. SDK size bir de `Client` verir: bir host'un bir sunucuya URL ile ulaşmak ya da onu alt süreç olarak başlatmak için kullanacağı sınıfın ta kendisi. Bu sayfanın ilerisinde karşınıza çıkar; sunucularınızı da onunla test edeceksiniz. ## Üç temel öğe {#the-three-primitives} @@ -83,22 +83,20 @@ Inspector'da üç sekme gördünüz. Üç tane olduğunu nereden bildi? Bir istemci bağlandığında sunucu **yeteneklerini** beyan eder: hangi istek ailelerini yanıtlayacağını. İstemci, neyi isteyeceğine karar vermek için bu beyanı kullanır. Bunu siz hiç yazmadınız; `MCPServer` sizin yerinize beyan eder. -Kendiniz bakın. SDK'nın `Client`'ı sunucu nesnesini doğrudan kabul eder ve ona **bellek içinde** bağlanır (alt süreç yok, port yok): +Kendiniz bakın. Bir terminalde `server.py` dosyasını HTTP üzerinden çalışır halde bırakın: -```python -import asyncio +```console +uv run mcp run server.py --transport streamable-http +``` -from mcp import Client +ve başka bir terminalden bir istemciyi ona yöneltin: -from server import mcp +```python title="client.py" hl_lines="7-8" +--8<-- "docs_src/first_steps/tutorial001_client.py" +``` - -async def main() -> None: - async with Client(mcp) as client: - print(client.server_capabilities.model_dump(exclude_none=True)) - - -asyncio.run(main()) +```console +python client.py ``` ```text @@ -115,11 +113,12 @@ Bu sözlük, sunucunuzun beyan ettiği **yeteneklerdir**. Bağlanan her istemcin `MCPServer` üç temel öğenin hepsini sunar; bu yüzden üçü de her zaman beyan edilir. -Orada ne olmadığına dikkat edin. `completions` (kaynak şablonları ve prompt'lar için argüman otomatik tamamlama) sizin yazacağınız bir işleyici gerektirir; bu sunucuda yok, dolayısıyla yetenek de yok ve uslu bir istemci sormaz. İsteğe bağlı her şey için kural budur: şeyi kaydedin, yetenek belirir; **[Tamamlamalar](../servers/completions.md)** bunu kanıtlar. +Orada ne olmadığına dikkat edin. `completions` (kaynak şablonları ve prompt'lar için argüman otomatik tamamlama) sizin yazacağınız bir işleyici gerektirir; bu sunucuda yok, dolayısıyla yetenek de yok ve kurallara uyan bir istemci sormaz. İsteğe bağlı her şey için kural budur: şeyi kaydedin, yetenek belirir; **[Tamamlamalar](../servers/completions.md)** bunu kanıtlar. !!! info - `Client(mcp)`, bu belgelerdeki her örneğin test edildiği aynı bellek içi istemcidir; - sizinkileri de böyle test edeceksiniz. Kendine ait koca bir sayfası var: **[Test etme](testing.md)**. + Bu `client.py` eksiksiz bir MCP istemcisidir; sayfası da **[İstemci](../client/index.md)**. + Bir testte terminali ve portu atlar, `Client`'a doğrudan sunucu nesnesinin kendisini verirsiniz: + `Client(mcp)`. Onun da kendine ait koca bir sayfası var: **[Test etme](testing.md)**. ## Yazmadıklarınız {#what-you-did-not-write} @@ -128,7 +127,7 @@ Bu sayfaya dönüp bir bakın. Üç küçük Python fonksiyonu yazdınız. Şunl * Bir JSON Schema. `a: int, b: int`, `add` şemasının *ta kendisidir*. * Bir istek işleyici. `tools/list`, `resources/read`, `prompts/get`: hepsi sizin yerinize sunulur. * Bir yetenek beyanı. `MCPServer` onu sizin yerinize yaptı. -* Tek satır protokol. Sürüm anlaşması, JSON-RPC çerçevelemesi, yetenek değiş tokuşu: hepsi `mcp dev` ve `Client(mcp)` içinde oldu ve siz hiçbirini görmediniz. +* Tek satır protokol. Sürüm anlaşması, JSON-RPC çerçevelemesi, yetenek değiş tokuşu: hepsi `mcp dev` ve `client.py` içinde oldu ve siz hiçbirini görmediniz. SDK'nın bütün meselesi bu oran. @@ -139,6 +138,6 @@ SDK'nın bütün meselesi bu oran. * Her temel öğe için bir dekoratör: `@mcp.tool()`, `@mcp.resource(uri)`, `@mcp.prompt()`. Ad, açıklama ve şema fonksiyondan gelir. * İçinde `{param}` olan bir URI, somut kaynaklardan ayrı listelenen bir kaynak **şablonu** oluşturur. * Sunucunun **yetenekleri** sizin yerinize beyan edilir ve bir istemci yalnızca sunucunun beyan ettiklerini ister. -* `Client(mcp)` sunucu nesnesine bellek içinde bağlanır: ilk günden test düzeneğiniz. +* `Client("http://localhost:8000/mcp")` çalışan sunucunuzla konuşur. Ona bunun yerine sunucu nesnesini verin, `Client(mcp)`, ve ilk günden test düzeneğiniz olur. Sırada **[Gerçek bir host'a bağlanma](real-host.md)** var: bu sunucu, gerçekten, Claude Desktop'ın ya da bir IDE'nin içinde. Ardından **[Test etme](testing.md)**: bir sayfa, bir bellek içi istemci ve çalışıp çalışmadığını bir daha asla tahmin etmek zorunda kalmazsınız. Ondan sonra her temel öğenin kendi sayfası var; modelin yönettiğiyle başlıyoruz: **[Araçlar](../servers/tools.md)**. diff --git a/i18n/tr/pages/get-started/testing.md b/i18n/tr/pages/get-started/testing.md index a5ce1e0c..8b61d6f5 100644 --- a/i18n/tr/pages/get-started/testing.md +++ b/i18n/tr/pages/get-started/testing.md @@ -1,13 +1,13 @@ --- translation: - sections: ['4926721070127497', c52a1de2b6b32f40, 8e792bf8c7489ec6, 627195f7159e24ef] + sections: [5d13c2f0ba42c0d2, c52a1de2b6b32f40, 8e792bf8c7489ec6, 38552ea228b0a04f] tool: 1 --- # Test etme {#testing} -Python SDK, **bellek içi aktarıma** sahip bir `Client` sınıfıyla gelir: ona sunucu nesnenizi geçirirsiniz, o da doğrudan bağlanır. +SDK'nın `Client` sınıfı, yani bir URL'ye bağlanan ya da bir alt süreç başlatan sınıfın ta kendisi, **bellek içinde** de bağlanır: ona sunucu nesnenizi geçirin, o da sunucuyla doğrudan konuşur. -Alt süreç yok. Port yok. Hiç aktarım yok. FastAPI'nin `TestClient`'ıyla aynı fikir. +Alt süreç yok. Port yok. Ağ üzerinden giden hiçbir şey yok. FastAPI'nin `TestClient`'ıyla aynı fikir. ## Temel kullanım {#basic-usage} @@ -97,7 +97,7 @@ budur: testiniz temizlenmiş mesaj yerine gerçek mesajı görür. Testlerde açık bırakın. Üretim kodunda bir anlamı yoktur. -## Varsayılan olarak süreç içi {#in-process-by-default} +## Varsayılan olarak nesilden bağımsız {#era-neutral-by-default} !!! note `Client(mcp)` süreç içinde bağlanır ve varsayılan olarak **nesilden bağımsızdır**: sunucuyu diff --git a/i18n/tr/pages/handlers/multi-round-trip.md b/i18n/tr/pages/handlers/multi-round-trip.md index e4fe64c1..939a1006 100644 --- a/i18n/tr/pages/handlers/multi-round-trip.md +++ b/i18n/tr/pages/handlers/multi-round-trip.md @@ -1,6 +1,6 @@ --- translation: - sections: [74011e683045eea9, 9b64cc175c18b6a9, 4b41be4824030397, e3b1502da786ec33, 71e41161f143c6a9, 9ec2c1eeb8c36378, 8dd027377d46448b, f81491125dcbfe8b] + sections: [74011e683045eea9, 9b64cc175c18b6a9, 4b41be4824030397, e3b1502da786ec33, 71e41161f143c6a9, 9ec2c1eeb8c36378, b47667184ca5b516, f81491125dcbfe8b] tool: 1 --- # Çok turlu istekler {#multi-round-trip-requests} @@ -164,7 +164,7 @@ Düşük seviyeli `Server` hiçbir şeyin hazır gelmediği katmandır: `MCPServ ## 2026-07-28 sürümüne özgü bir sonuç {#a-2026-07-28-result} -`InputRequiredResult` yalnızca **2026-07-28** protokol sürümünde vardır. Bellek içi `Client(server)` onu sizin yerinize anlaşarak belirler; ağ üzerinde `mode="auto"` keşfeder. Bağlandıktan sonra `client.protocol_version` size ne elde ettiğinizi söyler. +`InputRequiredResult` yalnızca **2026-07-28** protokol sürümünde vardır. `Client`'ın varsayılan `mode="auto"` ayarı onu her bağlantıda keşfeder. Bağlandıktan sonra `client.protocol_version` size ne elde ettiğinizi söyler. !!! warning 2026 öncesi bir oturumda `InputRequiredResult` koyacak bir yer yoktur. `mode="legacy"` bir diff --git a/i18n/tr/pages/handlers/progress.md b/i18n/tr/pages/handlers/progress.md index 373b6394..98d86bb9 100644 --- a/i18n/tr/pages/handlers/progress.md +++ b/i18n/tr/pages/handlers/progress.md @@ -1,6 +1,6 @@ --- translation: - sections: [5315262fe26b33e1, 9d8e98840f1b78f0, 0284b215e85366c4, 8534d8dbb4053a70, 2966fac6fe697007] + sections: [5315262fe26b33e1, 9d8e98840f1b78f0, 52d6009a07e770ea, 8534d8dbb4053a70, 2966fac6fe697007] tool: 1 --- # İlerleme {#progress} @@ -29,19 +29,17 @@ Bir **`Context`** parametresi alın ve `report_progress`'i çağırın: İstemci, `call_tool`'a `progress_callback=` geçirerek **çağrı başına** dahil olur: -```python title="client.py" hl_lines="7 16" +```python title="client.py" hl_lines="5 14" import anyio from mcp import Client -from server import mcp - async def show(progress: float, total: float | None, message: str | None) -> None: print(f"{message} ({progress}/{total})") async def main() -> None: - async with Client(mcp) as client: + async with Client("http://localhost:8000/mcp") as client: result = await client.call_tool( "import_catalog", {"urls": ["https://example.com/a.json", "https://example.com/b.json"]}, @@ -56,28 +54,31 @@ anyio.run(main) Callback, sunucunun bildirdiklerini olduğu gibi alan `async` bir fonksiyondur: `progress`, `total`, `message`. !!! info - `Client(mcp)` doğrudan sunucu nesnesine, bellek içinde bağlanır; **[Test etme](../get-started/testing.md)** - sayfasının üzerine kurulduğu istemcinin aynısıdır. `Client` hangi aktarımı kullanırsa kullansın - `progress_callback` aynı parametredir; birazdan göreceğiniz *zamanlama* ise bellek içi bağlantıya - özgüdür. Bu bağlantı callback'inizi satır içinde çalıştırır, bu yüzden her bildirim `call_tool` - dönmeden önce ulaşır. Gerçek bir aktarım üzerinde bildirimler sonuçla yarışır ve yavaş bir callback, - `call_tool` döndükten sonra hâlâ çalışıyor olabilir. + `Client`'a ne verirseniz verin `progress_callback` aynı parametredir: buradaki gibi bir URL, bir + `StdioServerParameters` ya da testteki sunucu nesnesi. Yine de gerçek bir aktarım üzerinde + zamanlamaya dikkat edin. Her bildirim yanıtın yanında, kendi başına iletilir; bu yüzden yavaş bir + callback, `call_tool` döndükten sonra hâlâ çalışıyor olabilir. Yalnızca süreç içi test bağlantısı + callback'i satır içinde çalıştırır ve her bildirimin önce ulaşmasını garanti eder. ### Deneyin {#try-it} -`client.py` dosyasını `server.py` dosyasının yanına koyun ve çalıştırın: +`server.py` dosyasını HTTP üzerinden sunun, ardından istemciyi ikinci bir terminalden çalıştırın: + +```console +uv run mcp run server.py --transport streamable-http +``` ```console python client.py ``` ```text -Imported https://example.com/a.json (1/2) -Imported https://example.com/b.json (2/2) +Imported https://example.com/a.json (1.0/2.0) +Imported https://example.com/b.json (2.0/2.0) {'result': 'Imported 2 records.'} ``` -Sunucudaki her `await ctx.report_progress(...)`, istemcide sırasıyla bir `show` çağrısına dönüştü ve her iki satır da `call_tool` dönmeden **önce** yazdırıldı. İlerleme sonucun içine paketlenmez; araç hâlâ çalışırken akar. +Sunucudaki her `await ctx.report_progress(...)`, istemcide sırasıyla bir `show` çağrısına dönüştü. İlerleme sonucun içine paketlenmez. Araç hâlâ çalışırken akar. !!! warning `progress_callback` `Client`'a değil, **çağrıya** aittir. Bunun için bir kurucu argümanı yoktur, diff --git a/i18n/tr/pages/protocol-versions.md b/i18n/tr/pages/protocol-versions.md index b1fbd766..8705fc92 100644 --- a/i18n/tr/pages/protocol-versions.md +++ b/i18n/tr/pages/protocol-versions.md @@ -1,6 +1,6 @@ --- translation: - sections: [478fd619e5f90ef8, aef094a00e44e248, bab8cbf3449fa7e9, df1809b15a58335b, 5f9d8c2336ed0239, f54974398e43ddef, b24443dd78584870] + sections: [d98e337bf28845e0, dd642acbba36f615, f547b3e76da19c94, 149513378588da5c, 4e149ed992046709, f54974398e43ddef, b24443dd78584870] tool: 1 --- # Protokol sürümleri {#protocol-versions} @@ -11,9 +11,17 @@ MCP'nin iki nesli var. Bununla neredeyse hiç ilgilenmeniz gerekmez, çünkü anlaşmayı sizin yerinize `Client` yapar. Bu sayfa, bunu denetleyen tek yapıcı argümanı, yani `mode=` parametresini ve onu değiştireceğiniz üç durumu anlatır. +Bu sayfadaki her kod parçası, **[İstemci](client/index.md)** sayfasındaki Bookshop `server.py` sunucusuyla konuşan bir `client.py` dosyasıdır. O sunucuyu bir terminalde başlatın: + +```console +uv run mcp run server.py --transport streamable-http +``` + +Ardından her kod parçasını ikinci bir terminalde `python client.py` ile çalıştırın. + ## `mode="auto"` {#modeauto} -```python title="client.py" hl_lines="14-15" +```python title="client.py" hl_lines="7-8" --8<-- "docs_src/protocol_versions/tutorial001.py" ``` @@ -31,13 +39,14 @@ Her iki durumda da bağlanmış olarak çıkarsınız ve hangisinin gerçekleşt Özelliğin tamamı bu. Tek bir `Client`, her nesilden sunucu, kodunuzda dallanma yok. !!! info - `MCPServer`, `server/discover` isteğini her aktarımda yanıtlar (bellek içi, stdio, Streamable - HTTP); bu yüzden kendi sunucunuza karşı `auto` her zaman `2026-07-28`'e ulaşır. Geri dönüş - yalnızca gerçek bir 2026 öncesi sunucuya karşı devreye girer, ki tam da o zaman bunu istersiniz. + `MCPServer`, `server/discover` isteğini her aktarımda yanıtlar (Streamable HTTP, stdio ve + testlerinizin kullandığı süreç içi bağlantı); bu yüzden kendi sunucunuza karşı `auto` her zaman + `2026-07-28`'e ulaşır. Geri dönüş yalnızca gerçek bir 2026 öncesi sunucuya karşı devreye girer, + ki tam da o zaman bunu istersiniz. ## `mode="legacy"` {#modelegacy} -```python title="client.py" hl_lines="14" +```python title="client.py" hl_lines="7" --8<-- "docs_src/protocol_versions/tutorial002.py" ``` @@ -61,7 +70,7 @@ Sunucunun başlattığı bir istek, sunucunun *sizi* çağırmasıdır: `ctx.eli `mode`, modern bir protokol sürümü dizgesini de kabul eder. Bugün bu küme tam olarak `["2026-07-28"]`. -```python title="client.py" hl_lines="14" +```python title="client.py" hl_lines="7" --8<-- "docs_src/protocol_versions/tutorial003.py" ``` @@ -94,7 +103,7 @@ Sorgu ucuzdur, ancak yine de her yeniden bağlanmada ödediğiniz bir turdur ve Öyleyse saklayın. Bir `auto` bağlantısından sonra `client.session.discover_result`, sunucunun gönderdiği `DiscoverResult`'ı olduğu gibi tutar: `supported_versions`, `capabilities`, `instructions` ve sunucunun sonucun `_meta` alanına işlediği kimlik. Bir sonraki sefer bunu `prior_discover=` olarak geri verin: -```python title="client.py" hl_lines="15 17" +```python title="client.py" hl_lines="8 10" --8<-- "docs_src/protocol_versions/tutorial004.py" ``` diff --git a/i18n/tr/pages/run/asgi.md b/i18n/tr/pages/run/asgi.md index 7ed6be8d..4a3676c5 100644 --- a/i18n/tr/pages/run/asgi.md +++ b/i18n/tr/pages/run/asgi.md @@ -1,6 +1,6 @@ --- translation: - sections: [1062ef792791488a, 4be2b831547184a9, 374b049e770385f2, b72f6947089e6de0, b172c9db7831bb31, 70b9ece244ca1b0c, cba78e052898c3f6, f06bdb541cb0b469, fb82d526320b7cc3] + sections: [1062ef792791488a, 4be2b831547184a9, 374b049e770385f2, b72f6947089e6de0, b172c9db7831bb31, 10394c6f16601638, cba78e052898c3f6, f06bdb541cb0b469, 6dc898ccc5a903f9] tool: 1 --- # Mevcut bir uygulamaya ekleme {#add-to-an-existing-app} @@ -100,7 +100,7 @@ Sondaki o `/mcp`, `streamable_http_path` değeridir. Bunu `"/"` yapın, mount ö --8<-- "docs_src/asgi/tutorial004.py" ``` -Artık istemciler `/notes/mcp` yoluna değil `/notes` yoluna bağlanır. +Artık istemciler `/notes/mcp` yoluna değil `/notes/` yoluna bağlanır. ## Tarayıcı istemcileri için CORS {#cors-for-browser-clients} @@ -143,4 +143,4 @@ Tarayıcı tabanlı bir istemcinin sizden iki izne ihtiyacı vardır: MCP istek * Tarayıcı istemcilerinin CORS'a ihtiyacı vardır: `Mcp-*` istek başlıkları için `allow_headers`, yanıt için `expose_headers=["Mcp-Session-Id"]`. * `@mcp.custom_route()`, `/mcp`'nin yanına düz, kimlik doğrulaması olmayan HTTP endpoint'leri ekler. -Sunucu gerçek bir URL'den erişilebilir olduğunda **[İstemci](../client/index.md)** ona bir sunucu nesnesi yerine o URL ile bağlanır. +Sunucu gerçek bir URL'den erişilebilir olduğunda **[İstemci](../client/index.md)** ona o URL ile bağlanır. diff --git a/i18n/tr/pages/run/authorization.md b/i18n/tr/pages/run/authorization.md index c04c1eef..484d7b42 100644 --- a/i18n/tr/pages/run/authorization.md +++ b/i18n/tr/pages/run/authorization.md @@ -1,6 +1,6 @@ --- translation: - sections: [d62c13457fc4a534, 80e73abaca6e0652, d1dc4c54cd00ec9c, 14ad3bc7904036bb, 5225f127bc1b9c77, fe1626fdd5aad1da, 4556cb7ea1a04a31] + sections: [d62c13457fc4a534, 80e73abaca6e0652, 128a492d18295f64, 14ad3bc7904036bb, 54a6697833fcc1d9, fe1626fdd5aad1da, 811d083c1da8bcf6] tool: 1 --- # Yetkilendirme {#authorization} @@ -23,12 +23,12 @@ Bu sayfa sunucu tarafını anlatır. Yetkilendirme sunucunuzu keşfeden ve token SDK'nın geçerli bir token'ın neye benzediği konusunda bir fikri yoktur. Bunu **`TokenVerifier`**'ı uygulayarak siz söylersiniz: -```python title="server.py" hl_lines="12-14 19-24" +```python title="server.py" hl_lines="14-16 21-27" --8<-- "docs_src/authorization/tutorial001.py" ``` * `TokenVerifier` tek bir asenkron metodu olan bir protokoldür. `verify_token`, `Authorization` başlığındaki ham token'ı alır; geçerliyse bir **`AccessToken`**, değilse `None` döndürür. Uygulanacak başka bir şey yok. -* Buradaki, token'ı bir tabloda arar. Gerçek bir doğrulayıcı JWT imzasını doğrular ya da yetkilendirme sunucusunun token-introspection endpoint'ini çağırır. O kod sizindir; SDK onu yalnızca çağırır. +* Buradaki, token'ı bir tabloda arar; her girdi, token'ın hangi kaynak için verildiğini kaydeder. Gerçek bir doğrulayıcı JWT imzasını doğrular ya da yetkilendirme sunucusunun token-introspection endpoint'ini çağırır ve token'ın kimin için verildiğini (`aud` değerini) `AccessToken.resource` içinde bildirir. O kod sizindir; SDK onu yalnızca çağırır. * `token_verifier=` ve `auth=` her zaman birlikte kullanılır. Birini diğeri olmadan geçirirseniz `MCPServer(...)` daha tek bir istek sunmadan `ValueError` fırlatır. `AuthSettings`, kaynak sunucunuzun dışa dönük yüzüdür: @@ -36,6 +36,10 @@ SDK'nın geçerli bir token'ın neye benzediği konusunda bir fikri yoktur. Bunu * `issuer_url`: token'larınızı veren yetkilendirme sunucusu. * `resource_server_url`: bu MCP endpoint'inin herkese açık URL'si. Bir token'ın *hangi* kaynak için olduğunu belirtir ve keşif belgesi burada bulunur. * `required_scopes`: her token bunların hepsini taşımalıdır. +* `validate_token_resource`: `AccessToken.resource` değeri `resource_server_url` olmayan her token'ı reddeder. `resource_server_url` ayarlıyken bunu ayarlamadan bırakmak uyarı verir (`MCPDeprecationWarning`) ve `False` gibi davranır; 3.0 sürümü kaynak sunucuları için varsayılanı `True` yapar. + * Yetkilendirme sunucunuz token'ları istemcinin istediği `resource` değerine bağlıyorsa açın; MCP istemcileri bu değeri her zaman gönderir. `resource_server_url` değerini istemcilerin bağlandığı URL'nin birebir aynısı olarak tutun. + * Yetkilendirme sunucunuz kendi audience tanımlayıcılarını kullanıyorsa (bir Auth0 API tanımlayıcısı, bir Entra uygulama kimliği) kapalı bırakın ve bunun yerine `aud` değerini doğrulayıcınızda denetleyin; bu sunucu için olmayan bir token'da `None` döndürün. + * `aud` bir listeyse, `resource_server_url` ile eşit olan girdiyi `resource` alanına koyun. !!! tip SDK deposundaki `examples/servers/simple-auth/` dizininde, gerçek bir yetkilendirme sunucusunun @@ -91,7 +95,7 @@ Sunucunuzu hiç duymamış bir istemci içeri giden yolu bu belgeyle bulur: `aut Herhangi bir işleyicinin içinde **`get_access_token()`**, doğrulayıcınızın geçerli istek için döndürdüğü `AccessToken`'dır: -```python title="server.py" hl_lines="4 32-35" +```python title="server.py" hl_lines="4 35-38" --8<-- "docs_src/authorization/tutorial002.py" ``` @@ -125,6 +129,6 @@ Bir yetkilendirme sunucusu, kullanıcının onay ekranından tıklayarak geçmes * `token_verifier=` ve `auth=AuthSettings(issuer_url=..., resource_server_url=..., required_scopes=[...])` her zaman birlikte kullanılır. * SDK, [RFC 9728](https://datatracker.ietf.org/doc/html/rfc9728) Protected Resource Metadata belgesini `/.well-known/oauth-protected-resource/...` altında yayımlar ve kimliği doğrulanmamış istekleri, `WWW-Authenticate` başlığı ona işaret eden bir 401 ile yanıtlar. Keşif hikâyesinin tamamı bu. * Herhangi bir işleyicide `get_access_token()`, kimin çağırdığını söyler. -* Yetkilendirme bir HTTP meselesidir. `stdio` ve bellek içi istemci onu hiç görmez. +* Yetkilendirme bir HTTP meselesidir. `stdio` ve bellek içi test istemcisi onu hiç görmez. İstemci yarısı (yetkilendirme sunucunuzu keşfetme ve token'ı sizin yerinize alma) **[OAuth istemcileri](../client/oauth-clients.md)** sayfasında. Kullanıcıdan kimlik istemek yerine bir kimliği *beyan eden* istemci ise **[Kimlik beyanı](../client/identity-assertion.md)** sayfasında. diff --git a/i18n/tr/pages/run/deploy.md b/i18n/tr/pages/run/deploy.md index 3155935d..29a6eaf4 100644 --- a/i18n/tr/pages/run/deploy.md +++ b/i18n/tr/pages/run/deploy.md @@ -1,6 +1,6 @@ --- translation: - sections: [28221886b198784f, f88ea1f1614f3a1d, ce926d686730b6d0, 3be24f8ad8bb5ab9, 3fad24032b2224ff, f25a7f860e579ecb, e758745df6fb7b0a] + sections: [28221886b198784f, f88ea1f1614f3a1d, 2e76f5cb9df15042, ce926d686730b6d0, 3be24f8ad8bb5ab9, 3fad24032b2224ff, f25a7f860e579ecb, 697b01d95080880d] tool: 1 --- # Dağıtım ve ölçekleme {#deploy-scale} @@ -47,6 +47,22 @@ Gerçek bir ana bilgisayar adının arkasına dağıtıldığında, aynı varsay her bağlantıyı reddeden bir sunucu, aksi kanıtlanana kadar bir Host izin listesi sorunudur. **[Sorun giderme](../troubleshooting.md)** de buradan başlar. +## TLS sonlandıran bir vekil sunucunun arkasında {#behind-a-tls-terminating-proxy} + +TLS bir vekil sunucuda (bir ingress, bir yük dengeleyici, Caddy, nginx) sona eriyor ve uvicorn onun arkasında düz HTTP sunuyorsa, uvicorn'a vekil sunucunun `X-Forwarded-*` başlıklarına güvenmesini söyleyin: + +```console +uvicorn server:app --proxy-headers --forwarded-allow-ips='' +``` + +Bu olmadan uygulama `http://` üzerinden sunulduğunu sanır ve verdiği her yönlendirme (en yaygını `/mcp` → `/mcp/`) `http://…` adresini gösterir. Python istemcisi bir HTTPS endpoint'inden düz HTTP'ye giden bir yönlendirmeyi izlemeyi reddeder ve bunu açıkça söyler: + +```text +MCPError: Redirect to http://mcp.example.com/mcp/ not followed: it would downgrade this HTTPS endpoint to plain HTTP. +``` + +İstemci tarafındaki geçici çözüm, sunucunun sunduğu tam URL'yi (`https://mcp.example.com/mcp/`, sondaki eğik çizgi dahil) yapılandırmaktır; böylece hiç yönlendirme olmaz. Asıl çözüm yukarıdaki bayraktır. `FORWARDED_ALLOW_IPS` aynı ayarın ortam değişkeni yazımıdır; `*` her atlama noktasına güvenir, bu da yalnızca uvicorn'a vekil sunucudan başka hiçbir şey ulaşamıyorsa doğrudur. + ## Worker'lar ve kimin yapışkan olması gerektiği {#workers-and-who-has-to-be-sticky} Ana bilgisayar adı yanıt vermeye başladıktan sonra, arkasına birden fazla worker koyun. Bunun için SDK'da bir ayar yoktur; bir Starlette uygulamasını, herhangi bir ASGI uygulamasını ölçeklediğiniz gibi ölçeklersiniz: nesneyi, fork etmeyi bilen bir şeye verirsiniz: @@ -171,6 +187,7 @@ Bir `MCPServer` bir uygulama sunucusu değil, bir protokol gerçeklemesidir. Bun ## Özet {#recap} * Varsayılan olarak uygulama yalnızca localhost'a gönderilen istekleri yanıtlar. `transport_security=TransportSecuritySettings(allowed_hosts=[...], allowed_origins=[...])` yayına çıkış kapısıdır: onu geçirene kadar gerçek bir ana bilgisayar adının arkasındaki her istek bir `421`'dir ve nedeni yalnızca sunucunun log'undadır. +* TLS sonlandıran bir vekil sunucunun arkasında uvicorn'u `--proxy-headers --forwarded-allow-ips=...` ile çalıştırın; yoksa yönlendirmeleri `http://` adresini gösterir ve istemci onları reddeder. * 2026-07-28'de oturum yoktur ve bir yük dengeleyicinin yapışacağı hiçbir şey yoktur. `stateless_http=True` yalnızca eski nesle ait bir ayardır, çünkü modern bir istek o bayrak hiç okunmadan yönlendirilir ve yanıtlanır. * Varsayılan `requestState` anahtarı, süreç başına basılan `os.urandom(32)`'dir. Farklı bir worker'a ulaşan çok turlu bir yeniden deneme `-32602` *"Invalid or expired requestState"* ile başarısız olur. * Çözüm `RequestStateSecurity(keys=[...])` **ve** her örnekte aynı sunucu adıdır. Ad, token'ın varsayılan audience claim'idir. Aynı anahtarlar, aynı ad. diff --git a/i18n/tr/pages/run/index.md b/i18n/tr/pages/run/index.md index b4c20621..fe5c4147 100644 --- a/i18n/tr/pages/run/index.md +++ b/i18n/tr/pages/run/index.md @@ -1,6 +1,6 @@ --- translation: - sections: [fea8d769ff9edeba, ce8e2ad42f29ef71, 0d705efb19cf99c2, 2fd7cf825e6d2b2c, 9adc400e8c88e854, 318893ad8e2e9924, 6b63ab96b34476c0] + sections: [fea8d769ff9edeba, ce8e2ad42f29ef71, 0d705efb19cf99c2, 9fd2357154a5b7e7, 9adc400e8c88e854, 318893ad8e2e9924, 6b63ab96b34476c0] tool: 1 --- # Sunucunuzu çalıştırma {#running-your-server} @@ -75,6 +75,11 @@ Her aktarımın kendi anahtar sözcük argümanları vardır ve hepsi `run()` ü * `max_request_body_size`: bayt cinsinden kabul edilen en büyük istek gövdesi. Varsayılan olarak 4 MiB; daha büyük istekler, ayrıştırma veya oturum oluşturma öncesinde HTTP 413 alır. Bunu yalnızca meşru MCP mesajları bu boyutu aştığında yükseltin. +* `session_idle_timeout`: eski nesil bir oturumun, sunucu onu kapatmadan önce sürmekte olan hiçbir şey + olmadan bekleyebileceği saniye sayısı. Varsayılan 1800. `None` bunu devre dışı bırakır. Bkz. + [Oturum ömrü ve sınırlar](legacy-clients.md#session-lifetime-and-limits). +* `max_sessions`: bir sürecin aynı anda kaç eski nesil oturum tuttuğu. Varsayılan 10 000. `None` + sınırı kaldırır. Aynı bölümde ele alınır. * `event_store`, `retry_interval`, `transport_security`: kaldığı yerden devam edebilme ve DNS rebinding koruması. localhost dışında bir yere dağıtım yapana kadar bekleyebilirler; `transport_security` konusunu **[Dağıtım ve ölçekleme](deploy.md)** ele alır. !!! warning diff --git a/i18n/tr/pages/run/legacy-clients.md b/i18n/tr/pages/run/legacy-clients.md index da62841d..055a9930 100644 --- a/i18n/tr/pages/run/legacy-clients.md +++ b/i18n/tr/pages/run/legacy-clients.md @@ -1,6 +1,6 @@ --- translation: - sections: [3d1663c18edc824c, d4fd37009a13f03d, af9f398a5a8b679a, 470c2dd144294d69, 8e45827e6d24e8c8, 91dfd0ce98ebb03c] + sections: [3d1663c18edc824c, 90956965ae6a1ca1, af9f398a5a8b679a, 5ce83b1f9d88da62, 0d9b5d13fffc94e5, 8e45827e6d24e8c8, 91dfd0ce98ebb03c] tool: 1 --- # Eski nesil istemcilere hizmet verme {#serving-legacy-clients} @@ -22,15 +22,25 @@ Yani eski nesil istemci, *ona göre* bir şey inşa ettiğiniz bir hedef değil. ## Tek işleyici, iki nesil {#one-handler-both-eras} -İşte kullanıcıya bir şey sorması gereken bir araç ve onu çağıran her iki nesilden istemci: +İşte kullanıcıya bir şey sorması gereken bir araç: -```python title="server.py" hl_lines="24 37-38" +```python title="server.py" hl_lines="21" --8<-- "docs_src/legacy_clients/tutorial001.py" ``` `reserve`, modelin sağlamadığı tek bir şeye ihtiyaç duyar: kaç kopya. Bir araç bunu `Annotated[..., Resolve(ask_quantity)]` ile bildirir (ayrıntıların tamamı **[Bağımlılıklar](../handlers/dependencies.md)** sayfasında). `reserve` içinde hiçbir şey bir sürüm adı vermez, bir yetenek kontrol etmez ya da dallanmaz. -İki istemci **aynı anda**, aynı `mcp` nesnesi üzerinde açıktır. `mode="legacy"`, `initialize` el sıkışmasını çalıştırır: 2026 öncesi bir istemcinin açtığı bağlantının ta kendisi. Diğeri varsayılanı alır ve `2026-07-28` sürümünde karar kılar. +Onu HTTP üzerinden sunun. İşte onu çağıran her iki nesilden istemci: + +```console +uv run mcp run server.py --transport streamable-http +``` + +```python title="client.py" hl_lines="14-15" +--8<-- "docs_src/legacy_clients/tutorial001_client.py" +``` + +İki istemci **aynı anda**, çalışan aynı sunucuya karşı açıktır. `mode="legacy"`, `initialize` el sıkışmasını çalıştırır: 2026 öncesi bir istemcinin açtığı bağlantının ta kendisi. Diğeri varsayılanı alır ve `2026-07-28` sürümünde karar kılar. İkinci bir terminalden `python client.py` komutunu çalıştırın: ```text 2025-11-25 {'result': "Reserved 2 of 'Dune'."} @@ -64,6 +74,31 @@ Tek worker'da bu görünmez. İki worker'da ise sorunun tamamı budur: `Mcp-Sess **devam ettirilebilirliktir** (kaçırılan SSE olaylarını *aynı* oturuma yeniden bağlanan bir istemciye yeniden oynatmak). Bir oturumu asla başka bir süreçten erişilebilir kılmaz. +## Oturum ömrü ve sınırlar {#session-lifetime-and-limits} + +Eski nesil bir oturum sonsuza dek yaşamaz ve tek bir süreç bunlardan sınırsız sayıda tutmaz. Bunu iki ayar denetler. İkisi de `run()`, `streamable_http_app()` ve `Server.streamable_http_app()` üzerinde birer anahtar sözcük argümanıdır. Modern (`2026-07-28`) bağlantılarda ve `stateless_http=True` durumunda oturum yoktur; bu yüzden iki ayar da onlar için geçerli değildir. + +| Ayar | Varsayılan | Ne yapar | İstemci ne görür | Kapatmak için | +|---|---|---|---|---| +| `session_idle_timeout` | `1800` (30 dk) | Bu süre boyunca devam eden hiçbir işlemi olmayan bir oturumu kapatır. | `404 Session not found`. Yeniden `initialize` göndermesi gerekir. | `None` | +| `max_sessions` | `10_000` | Bu sayının ötesinde oturum açmayı reddeder. Mevcut oturumlara dokunulmaz ve hiçbir şey tahliye edilmez. | JSON-RPC kodu `-32603` ile `503 Too many open sessions`. | `None` | + +Neler "devam eden işlem" sayılır: + +* Açık bir `GET` akışı. SDK istemcileri bir tane açık tutar; bu yüzden bağlı bir istemcinin oturumu asla sona ermez. +* Hâlâ yanıtlanmakta olan bir istek. Zaman aşımından uzun süren bir araç çağrısı kesilmez; geri sayım ancak o bittiğinde başlar. +* Başka hiçbir şey. İstekler arasında saat işler. Oturumdaki herhangi bir istek onu yeniden başlatır, `ping` dahil. Bir oturum sona erdikten sonra onu hiçbir şey diriltmez. + +Oturumunu `DELETE` ile bitiren bir istemci onu hemen serbest bırakır. Açılış isteği reddedilen bir istemci de öyle. + +```python +mcp.run(transport="streamable-http", session_idle_timeout=None, max_sessions=50_000) +``` + +İki olay da sunucu log'unda görünür. Sona erme, `INFO` düzeyinde `Session idle timeout` olarak görünür. Reddedilen bir açılış ise `WARNING` düzeyinde `Refusing to open a new session: sessions are already open` olarak. + +Sınırlar süreç başınadır. Dört worker'la tavan, `max_sessions` değerinin dört katıdır ve her worker kendi oturumlarını kendisi sonlandırır. + ## Tek ayar düğmesi: `stateless_http` {#the-one-knob-stateless_http} Yapışkanlık ödemeyi reddettiğiniz bir bedelse, değiştirebileceğiniz tam olarak tek bir şey var. @@ -89,8 +124,7 @@ Onunla ilgili iki şey, ne yaptığından daha önemli. !!! check Yanlış olanı yapın. `reserve`, az önce iki istemciye de hizmet veren aracın ta kendisi. Onu - `stateless_http=True` ile dağıtın, aynı iki istemciyi HTTP üzerinden bağlayın ve her birinden - çağırın. + `stateless_http=True` ile dağıtın, aynı iki istemciyi bağlayın ve her birinden çağırın. Modern istemci hâlâ `Reserved 2 of 'Dune'.` alır. Modern kol değişmedi. diff --git a/i18n/tr/pages/troubleshooting.md b/i18n/tr/pages/troubleshooting.md index 3362b530..5933595a 100644 --- a/i18n/tr/pages/troubleshooting.md +++ b/i18n/tr/pages/troubleshooting.md @@ -1,6 +1,6 @@ --- translation: - sections: [2efaecdef109a5c5, fcacd3e66b8635a4, 25323d737dcf0261, 8a6e351ec756904d, 137454d469c867f5, 6392596bd6df54f0, 41126fa9c4fe432f, 480b6d7897e30ab4, d83bb682e708dde0, ebbed3449c499db4, 323ef84f6b4bebde, 30fd31be74169d9a, 656943c6cb567218, c2dc3b1007d2e987, 7cf5386b997d04e9, 0b59feed8384456e, 0cba47bae78d04eb, e4355f4c7cf4fb2e] + sections: [3d58228e81b99543, 170514ce901c4139, 17d61fad0a50d62b, 8a6e351ec756904d, 137454d469c867f5, 6392596bd6df54f0, 41126fa9c4fe432f, 480b6d7897e30ab4, d83bb682e708dde0, ebbed3449c499db4, 525cdf1755e29d4c, 30fd31be74169d9a, d2e88333d4f7841f, c2dc3b1007d2e987, d6eabf60cc366341, f798e815252852c2, 0cba47bae78d04eb, 2c218ba829abf74e] tool: 1 --- # Sorun giderme {#troubleshooting} @@ -13,6 +13,12 @@ Girdilerin birkaçı şu tek sunucuya karşı çalışır. Bir araç ve bir şab --8<-- "docs_src/troubleshooting/tutorial001.py" ``` +Bu girdiler ona `http://localhost:8000/mcp` adresinden ulaşır; bu yüzden onu HTTP üzerinden çalışır durumda bırakın: + +```console +uv run mcp run server.py --transport streamable-http +``` + Bu sayfanın alıntıladığı hatalar gerçektir: SDK'nın kendi test paketi her birini yeniden üretir. ## `ExceptionGroup: unhandled errors in a TaskGroup (1 sub-exception)` {#exceptiongroup-unhandled-errors-in-a-taskgroup-1-sub-exception} @@ -23,7 +29,7 @@ Bu bir MCP hatası değil. anyio gürültüsüdür ve asıl hatanız yapıştır ```python async def main() -> None: - async with Client(mcp) as client: + async with Client("http://localhost:8000/mcp") as client: await client.read_resource("weather://Atlantis") ``` @@ -49,7 +55,7 @@ Bununla yapılacak iki şey var: ```python async def main() -> None: - async with Client(mcp) as client: + async with Client("http://localhost:8000/mcp") as client: try: await client.read_resource("weather://Atlantis") except MCPError as e: @@ -67,7 +73,7 @@ async def main() -> None: ```python async def main() -> None: - client = Client(mcp) + client = Client("http://localhost:8000/mcp") tools = await client.list_tools() # RuntimeError ``` @@ -75,7 +81,7 @@ async def main() -> None: ```python async def main() -> None: - async with Client(mcp) as client: + async with Client("http://localhost:8000/mcp") as client: tools = await client.list_tools() ``` @@ -252,7 +258,7 @@ Bunun sayfası, tek uygulamada birden fazla sunucu ve FastAPI dahil, **[Mevcut b ## `MCPError: Session not found` {#mcperror-session-not-found} -Sunucu, istemcinizin gönderdiği `Mcp-Session-Id`'yi tanımıyor; bunun nedeni neredeyse her zaman sunucunun **yeniden başlamış** olmasıdır (ya da farklı bir örneğe yönlendirilmişsinizdir). Oturumlar o tek sürecin belleğinde yaşar. +Sunucu, istemcinizin gönderdiği `Mcp-Session-Id`'yi tanımıyor. Ya sunucu **yeniden başladı** (ya da farklı bir örneğe yönlendirildiniz) ya da `session_idle_timeout` süresi boyunca (varsayılan olarak 30 dakika) hiçbir şey işlemde olmadığı için oturumun **süresi doldu**. [Oturum ömrü ve sınırları](run/legacy-clients.md#session-lifetime-and-limits) bölümüne bakın. Oturumlar o tek sürecin belleğinde yaşar. Bulunacak bir sunucu hatası yok. HTTP yanıtı, gövdesi JSON-RPC *olan* bir `404`'tür; bu yüzden yukarıdaki `421`'in aksine python `Client` bunu size birebir gösterir: @@ -262,9 +268,9 @@ Bulunacak bir sunucu hatası yok. HTTP yanıtı, gövdesi JSON-RPC *olan* bir `4 Çözüm yeniden bağlanmaktır: `async with Client(...)` bloğundan çıkın ve yeni bir oturum üzerinde anlaşan yeni bir bloğa girin. Uzun ömürlü bir istemci için bu, çağrılarınızın etrafında `MCPError`'ı yakalamak ve ölü bir oturumun içinde yeniden denemek yerine bu mesajda yeniden bağlanmak demektir. -Bu, yeniden başlatma *olmadan* oluyorsa, yapışkan oturumlar olmadan birden fazla worker çalıştırıyorsunuz demektir: her worker kendi oturum tablosunu tutar, bu yüzden yanlış olana yönlendirilen bir istek buraya düşer. Bu konu ve iki çözümü (yapışkan yönlendirme veya `stateless_http=True`) **[Dağıtım ve ölçekleme](run/deploy.md)** ile **[Eski nesil istemcilere hizmet verme](run/legacy-clients.md)** sayfalarında. +Bu, yeniden başlatma *olmadan* ve istemci o kadar uzun süre sessiz kalmadan oluyorsa, yapışkan oturumlar olmadan birden fazla worker çalıştırıyorsunuz demektir: her worker kendi oturum tablosunu tutar, bu yüzden yanlış olana yönlendirilen bir istek buraya düşer. Bu konu ve iki çözümü (yapışkan yönlendirme veya `stateless_http=True`) **[Dağıtım ve ölçekleme](run/deploy.md)** ile **[Eski nesil istemcilere hizmet verme](run/legacy-clients.md)** sayfalarında. -Sunucu operatörü için eşleşen log satırı `Rejected request with unknown or expired session ID: `'dir. `INFO` düzeyinde log'a yazılır; bu yüzden olağan `WARNING` eşiğinde görünmez. Bir dağıtımın hemen ardından bunu öbekler halinde görmek normaldir; bağlı her istemci yeniden bağlanıyordur. +Sunucu operatörü için eşleşen log satırı `Rejected request with unknown or expired session ID: `'dir. `INFO` düzeyinde log'a yazılır; bu yüzden olağan `WARNING` eşiğinde görünmez. Bir dağıtımın hemen ardından bunu öbekler halinde görmek normaldir; bağlı her istemci yeniden bağlanıyordur. Oturumun süresi dolduğunda ise bu satırdan önce, yine `INFO` düzeyinde, `Session idle timeout` gelir. ## `MCPError: Method not found` {#mcperror-method-not-found} @@ -276,7 +282,13 @@ Modern protokolün kaldırdığı bir istek olmasına rağmen bu hatayı **üret Sunucunuz kullanıcıya bir şey sormak istiyor ve bu istemci kendisine soru sorulabileceğini hiç söylemedi. -Bir elicitation (kullanıcıdan bilgi isteme) çözümleyicisi, bağlı istemci form elicitation'ı bildirmediğinde baştan reddeder ve `e.error.data` tam olarak neyin eksik olduğunu adlandırır: +Bu Bistro, rezervasyon yapmadan önce bir çözümleyici aracılığıyla sorar: + +```python title="server.py" hl_lines="15-17 21" +--8<-- "docs_src/troubleshooting/tutorial007.py" +``` + +Bunu Weather sunucusunun yerine sunun ve `book_table`'ı hiçbir `elicitation_callback` geçirmemiş bir istemciden çağırın. Çözümleyici baştan reddeder, çünkü bağlı istemci form elicitation'ı (kullanıcıdan bilgi isteme) hiç bildirmemiştir; `e.error.data` ise tam olarak neyin eksik olduğunu adlandırır: ```json { @@ -290,7 +302,7 @@ Bir elicitation (kullanıcıdan bilgi isteme) çözümleyicisi, bağlı istemci ```python async def main() -> None: - async with Client(mcp, elicitation_callback=handle_elicitation) as client: + async with Client("http://localhost:8000/mcp", elicitation_callback=handle_elicitation) as client: result = await client.call_tool("book_table", {"date": "Friday"}) ``` @@ -315,14 +327,14 @@ Bunu eski nesil bir bağlantıda `ctx.elicit()`'ten görürsünüz; herhangi bir İşleyiciniz, isteğin ortasında istemciye ulaşmaya çalıştı; hem de çağrısının sunucudan gelen bir isteği taşıyabilecek hiçbir kanalı olmayan bir bağlantıda. Bir çağrıyı bu duruma sokan üç sunucu yapılandırması var. -**Bir `2026-07-28` bağlantısı: her aktarımda, her zaman.** Modern protokolde sunucunun başlattığı istek diye bir şey hiç yoktur; bu yüzden sunucu daha hiçbir şey gönderilmeden reddeder. Bununla karşılaşmanın klasik yolu bir aracın içindeki `ctx.elicit()`'tir (hem de daha ilk bellek içi testte, çünkü `Client(server)` sorulmadan `2026-07-28` üzerinde anlaşır) ve `elicitation_callback=` geçirmek hiçbir şeyi değiştirmez, çünkü istemciye yanıtlayacağı bir istek hiç ulaşmaz: +**Bir `2026-07-28` bağlantısı: her aktarımda, her zaman.** Modern protokolde sunucunun başlattığı istek diye bir şey hiç yoktur; bu yüzden sunucu daha hiçbir şey gönderilmeden reddeder. Bununla karşılaşmanın klasik yolu bir aracın içindeki `ctx.elicit()`'tir; genellikle de o aracın daha ilk bellek içi **[testinde](get-started/testing.md)**, çünkü `Client(mcp)` sorulmadan `2026-07-28` üzerinde anlaşır. `elicitation_callback=` geçirmek hiçbir şeyi değiştirmez, çünkü istemciye yanıtlayacağı bir istek hiç ulaşmaz: ```python title="server.py" hl_lines="16" --8<-- "docs_src/troubleshooting/tutorial006.py" ``` ```python -async def main() -> None: +async def test_book_table() -> None: async with Client(mcp) as client: await client.call_tool("book_table", {"date": "Friday"}) ``` @@ -364,7 +376,7 @@ Sunucu, istemcinizin geri yansıttığı `requestState` token'ını doğrulayama ```python async def main() -> None: - async with Client(mcp) as client: + async with Client("http://localhost:8000/mcp") as client: await client.call_tool("forecast", {"city": "London"}, request_state="round-1-from-worker-a") ``` @@ -417,7 +429,7 @@ mcp = MCPServer("Weather", request_state_security=RequestStateSecurity(keys=[key * Sunucu log'undaki `Tool already exists:`, aynı adlı iki aracın teke indiğinin tek işaretidir. * Tek 421, üç yazım: `Server returned an error response` (python `Client`), `421 Misdirected Request` / `Invalid Host header` (geri kalan her şey), `Invalid Host header: ` (sunucu log'u). Çözüm: `transport_security=TransportSecuritySettings(allowed_hosts=[...])`. * `Task group is not initialized` -> ana uygulamanın lifespan'i `mcp.session_manager.run()`'a hiç girmemiş, bağlanmış bir uygulama. -* `Session not found` -> sunucu yeniden başladı; yeniden bağlanın. +* `Session not found` -> sunucu yeniden başladı ya da oturumun süresi doldu (`session_idle_timeout`); yeniden bağlanın. * `Cannot send 'elicitation/create': ... no back-channel ...` -> `ctx.elicit()` sunucudan istemciye bir kanala ihtiyaç duyar: bir `2026-07-28` bağlantısında hiç yoktur, `stateless_http=True` eski nesil olanı, `json_response=True` ise istek kapsamlı olanı ortadan kaldırır. Bir çözümleyici kullanın (eski nesil bir istemci için ayrıca kanalı koruyan bir sunucu gerekir). Komşusu `Method not found`, karşı tarafın protokol sürümünde olmayan bir yöntem için yapılmış bir istektir. * `Client did not declare the form elicitation capability ...` ve `Elicitation not supported` -> istemcide `elicitation_callback=` eksik. * `Invalid or expired requestState` nedenini ağ üzerinde asla söylemez. Sunucu log'u söyler; `unknown key`, `RequestStateSecurity(keys=[...])`'i worker'lar arasında paylaşın demektir. diff --git a/i18n/tr/pages/whats-new.md b/i18n/tr/pages/whats-new.md index 6380cf36..04297386 100644 --- a/i18n/tr/pages/whats-new.md +++ b/i18n/tr/pages/whats-new.md @@ -1,6 +1,6 @@ --- translation: - sections: [cfe01c0c5863dfa2, 1c58c5cfcc37d455, a7392996acf1ad8f, 875eb2889263424e] + sections: [cfe01c0c5863dfa2, 0dbb68d8b210177b, 80cf193023af3ed4, 875eb2889263424e] tool: 1 --- # v2'deki yenilikler {#whats-new-in-v2} @@ -42,11 +42,11 @@ Bir aracın ihtiyaç duyduğu her şey modelden gelmek zorunda değil. v2 ile ge v1 size iç içe üç katman veriyordu: ham akışlar üreten bir aktarım bağlam yöneticisi, bunların etrafına sarılmış bir `ClientSession` ve elle çağrılan bir `await session.initialize()`. v2'de tek bir nesne var: -```python title="client.py" hl_lines="14-18" ---8<-- "docs_src/client/tutorial001.py" +```python title="client.py" hl_lines="7-11" +--8<-- "docs_src/client/tutorial001_client.py" ``` -`Client` bir sunucu nesnesi (bellek içi, aktarım yok: test senaryosu), bir URL (Streamable HTTP), bir `StdioServerParameters` (bir stdio alt süreci) ya da `sse_client(...)` gibi başka herhangi bir aktarım bağlam yöneticisi alır. `async with` bloğuna girmek bağlantıyı kurar ve sunucu hangi nesli konuşuyorsa ona göre protokol sürümünde anlaşır; ardından `client.server_capabilities` ve `client.protocol_version` hazırdır, sunucu kendini tanıttığında `client.server_info` da öyle (artık `Implementation | None` türünde, çünkü 2026 neslinde kimlik isteğe bağlı). v1'de kaydettiğiniz örnekleme ve elicitation callback'leri hâlâ çalışır (gövdeleri, bu sayfadaki her şey gibi aynı snake_case öznitelik yeniden adlandırmasını görür); artık 2026 tarzı sonuç-içinde-isteklere de (aşağıda) yanıt verirler ve teker teker değil eşzamanlı çalışırlar. Düşük düzey yüzeyi isteyenler için `ClientSession` hâlâ altta duruyor ve `client.session` onu size verir; o da taşındı (yeni dispatcher motoru üzerinde çalışır ve kendi imzalarından bazıları değişti), bu yüzden aşağı inmeden önce **[Geçiş kılavuzu](migration.md#clientsession-now-runs-on-jsonrpcdispatcher-basesession-removed)** sayfasını okuyun. +`Client` bir URL (Streamable HTTP), bir `StdioServerParameters` (bir stdio alt süreci), `sse_client(...)` gibi başka herhangi bir aktarım bağlam yöneticisi ya da testlerde sunucu nesnesinin kendisini (bellek içi, aktarım yok) alır. `async with` bloğuna girmek bağlantıyı kurar ve sunucu hangi nesli konuşuyorsa ona göre protokol sürümünde anlaşır; ardından `client.server_capabilities` ve `client.protocol_version` hazırdır, sunucu kendini tanıttığında `client.server_info` da öyle (artık `Implementation | None` türünde, çünkü 2026 neslinde kimlik isteğe bağlı). v1'de kaydettiğiniz örnekleme ve elicitation callback'leri hâlâ çalışır (gövdeleri, bu sayfadaki her şey gibi aynı snake_case öznitelik yeniden adlandırmasını görür); artık 2026 tarzı sonuç-içinde-isteklere de (aşağıda) yanıt verirler ve teker teker değil eşzamanlı çalışırlar. Düşük düzey yüzeyi isteyenler için `ClientSession` hâlâ altta duruyor ve `client.session` onu size verir; o da taşındı (yeni dispatcher motoru üzerinde çalışır ve kendi imzalarından bazıları değişti), bu yüzden aşağı inmeden önce **[Geçiş kılavuzu](migration.md#clientsession-now-runs-on-jsonrpcdispatcher-basesession-removed)** sayfasını okuyun. **[Client](client/index.md)** sayfası onu tanıtır, **[İstemci aktarımları](client/transports.md)** dört bağlantı biçimini anlatır, **[İstemci callback'leri](client/callbacks.md)** callback'lerin kendisini ele alır ve **[Test etme](get-started/testing.md)** v1'in `create_connected_server_and_client_session()` yardımcısının yerini alan bellek içi kalıbı gösterir. @@ -171,11 +171,15 @@ Bunun istemci tarafı **[Protokol sürümleri](protocol-versions.md)** sayfasın Yerine gelen çözüm çağrıyı tersine çevirir. Kullanıcıdan bir şeye ihtiyaç duyan araç soruyu *döndürür* (`InputRequiredResult`), istemci onu her zamanki callback'leriyle yanıtlar ve çağrı yanıtlar eklenmiş hâlde yeniden denenir. Bu döngüyü sizin için `Client` yürütür. Sunucuda sonucu nadiren kendiniz kurarsınız, çünkü bunu bir **[bağımlılık](handlers/dependencies.md)** yapar: bir parametreyi `Resolve(ask_quantity)` ile işaretleyin (`ask_quantity` sizin yazdığınız sıradan bir fonksiyondur), SDK de bağlantının desteklediği mekanizma hangisiyse onun üzerinden sorar: eski nesil bir oturumda canlı bir elicitation isteği, 2026'da çok turlu bir istek. Tek araç gövdesi, iki nesil birden: -```python title="dual_era.py" hl_lines="24 37-38" +```python title="server.py" hl_lines="21" --8<-- "docs_src/legacy_clients/tutorial001.py" ``` -Bu dosya tüm vaadin tek yerde özeti: bir sunucu, `Resolve` destekli bir araç ve ikisi de yanıtını bellek içinde alan bir eski nesil istemci ile bir modern istemci. **[Çok turlu istekler](handlers/multi-round-trip.md)** mekanizmayı açıklar (SDK'nın sizin için mühürleyip doğruladığı `request_state` dâhil); sorma kısmı **[Elicitation](handlers/elicitation.md)** sayfasında. +```python title="client.py" hl_lines="14-15" +--8<-- "docs_src/legacy_clients/tutorial001_client.py" +``` + +Bu iki dosya tüm vaadin özeti: bir sunucu, `Resolve` destekli bir araç ve ikisi de yanıtını çalışan aynı sunucudan alan bir eski nesil istemci ile bir modern istemci (**[Eski nesil istemcilere hizmet verme](run/legacy-clients.md)** bunları adım adım anlatır). **[Çok turlu istekler](handlers/multi-round-trip.md)** mekanizmayı açıklar (SDK'nın sizin için mühürleyip doğruladığı `request_state` dâhil); sorma kısmı **[Elicitation](handlers/elicitation.md)** sayfasında. !!! warning "Taşınmış bir v1 sunucusunun davranış değiştirdiği tek yer burası" Buna ilk sizin testleriniz çarpar: `Client(mcp)` v2 sunucunuzla varsayılan olarak 2026-07-28 diff --git a/i18n/uk/pages/advanced/apps.md b/i18n/uk/pages/advanced/apps.md index 8929b7ae..76f5f941 100644 --- a/i18n/uk/pages/advanced/apps.md +++ b/i18n/uk/pages/advanced/apps.md @@ -1,6 +1,6 @@ --- translation: - sections: [0355618e5f4d5fe4, 1821eaf50f2d0b64, 82e0b28ebd3abf5a, 8ac39614c094f2d0, dab6ff945501ab2a, bd5565c3b2d4f959, 96819ce3d63a0487] + sections: [0355618e5f4d5fe4, 0fefa31fb7c7b585, 5c53e7487c9c70cc, 8ac39614c094f2d0, dab6ff945501ab2a, bd5565c3b2d4f959, 96819ce3d63a0487] tool: 1 --- # MCP Apps {#mcp-apps} @@ -18,13 +18,13 @@ SDK постачає це як вбудоване розширення `Apps` (` ## Годинник із циферблатом {#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" ``` Чотири кроки: -* `Apps()`: один екземпляр тримає ваші інструменти з прив'язаним UI та їхні ресурси. +* `Apps()`: один екземпляр тримає інструменти з прив'язаним UI та їхні ресурси. * `@apps.tool(resource_uri="ui://clock/app.html")`: звичайний інструмент плюс позначка `_meta.ui.resourceUri`. Усе, що приймає `@mcp.tool()` (name, title, description, ...), передається далі. * `apps.add_html_resource("ui://clock/app.html", CLOCK_HTML)`: відповідний ресурс, який віддається як `text/html;profile=mcp-app`. Саме цей MIME-тип каже хосту: «це застосунок, відобрази його». * `MCPServer("clock", extensions=[apps])`: увімкнення. Тепер сервер оголошує `io.modelcontextprotocol/ui` у `capabilities.extensions`. @@ -39,11 +39,31 @@ SDK постачає це як вбудоване розширення `Apps` (` Модель читає `content`; iframe — для людей. Хост із підтримкою UI все одно передає текстовий результат моделі, а суто текстовий клієнт отримує *лише* його. Тож канонічний шаблон — один інструмент, дві відповіді. Погляньте на `get_time` ще раз: -```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)` дорівнює `True` лише тоді, коли клієнт оголосив розширення `io.modelcontextprotocol/ui` **і** вказав `text/html;profile=mcp-app` у своїх налаштуваннях `mimeTypes`. Поле обов'язкове, тож клієнт, який його пропустив, не зараховується. Саме це й оголошує `main()` у тому самому файлі: клієнтську половину узгодження — і у відповідь приходить розширений варіант. +`client_supports_apps(ctx)` дорівнює `True` лише тоді, коли клієнт оголосив розширення `io.modelcontextprotocol/ui` **і** вказав `text/html;profile=mcp-app` у своїх налаштуваннях `mimeTypes`. Поле обов'язкове, тож клієнт, який його пропустив, не зараховується. Ось клієнтська половина узгодження: + +```python title="client.py" hl_lines="8 12" +--8<-- "docs_src/apps/tutorial001_client.py" +``` + +Віддайте `server.py` через HTTP, а потім із другого термінала запустіть клієнт: + +```console +uv run mcp run server.py --transport streamable-http +``` + +```console +python client.py +``` + +```text +2026-06-26T12:00:00Z +``` + +Повернулася розширена відповідь. Приберіть `extensions=[APPS_SUPPORT]` з виклику `Client` — і та сама програма натомість надрукує `The time is 2026-06-26T12:00:00Z.`, а це все, що коли-небудь бачить суто текстовий клієнт. !!! warning Ніколи не повертайте заглушку на кшталт `"[Rendered UI]"` як єдиний вміст. Якщо резервний текст марний, інструмент марний для кожного суто текстового клієнта й для самої моделі. Напишіть нормальне речення. diff --git a/i18n/uk/pages/advanced/extensions.md b/i18n/uk/pages/advanced/extensions.md index 9e73f6b6..1c935aec 100644 --- a/i18n/uk/pages/advanced/extensions.md +++ b/i18n/uk/pages/advanced/extensions.md @@ -1,6 +1,6 @@ --- translation: - sections: [05891e7cc1938a13, b3c01a6af28c51ee, 7ffc91f5e38bdfe0, 717d3f235a8333a7, f471a13b2fe5d737, ed6af2df4b656dff] + sections: [05891e7cc1938a13, b3c01a6af28c51ee, 6eba6ce094f4417e, 125f28588c85dd7b, ddd8969b698ca11c, ed6af2df4b656dff] tool: 1 --- # Розширення {#extensions} @@ -49,7 +49,7 @@ TypeError: Stamps.identifier must be a `vendor-prefix/name` string Найменше корисне розширення — один інструмент і карта налаштувань: -```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" ``` @@ -57,17 +57,23 @@ TypeError: Stamps.identifier must be a `vendor-prefix/name` string * `settings()` — це значення, що оголошується в `capabilities.extensions["com.example/stamps"]`. Поверніть `{}` (типове значення), щоб оголосити розширення без налаштувань. * Розширення ніколи не отримує сервер. Воно оголошує свій внесок як дані; `MCPServer` їх споживає. Ніякого `self.server`, який можна було б змінювати, немає. -А `main()` — доказ: клієнт у пам'яті, під'єднаний безпосередньо до `mcp`: +Запустіть його через HTTP, а доказом буде клієнт: -```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" +``` + +Кожен `server.py` на цій сторінці запускається цією командою, а кожен `client.py` працює поруч із ним: `python client.py` у другому терміналі. + ### Обслуговування власних методів {#serving-your-own-methods} Розширення може реєструвати **нові методи запитів**: власні дієслова, які обслуговуються поруч із методами специфікації: -```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" ``` @@ -83,14 +89,15 @@ TypeError: Stamps.identifier must be a `vendor-prefix/name` string ### Клієнтська сторона {#the-client-side} -`main()` у тому самому файлі — це вся клієнтська частина, обидві її половини: +Клієнт — окрема програма, і в ній обидві половини клієнтської частини: -```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)])` оголошує розширення. Оголошення стають `ClientCapabilities.extensions`: на з'єднанні версії 2026-07-28 карта подорожує в конверті `_meta` кожного запиту, тож сервер бачить її в **кожному** запиті; на з'єднанні старого покоління вона передається з рукостисканням `initialize`. Серверному коду байдуже, який саме шлях: `require_client_extension(ctx, ...)` і `ctx.session.check_client_capability(...)` читають правильне джерело в обох випадках. * Вендорські методи опускаються на один шар нижче, до `client.session.send_request(...)`; повноцінні методи в `Client` з'являються лише для дієслів специфікації. `send_request` приймає будь-який підклас `Request`, тож вендорський запит проходить як є. +* `SearchRequest` і дві моделі, які він несе, — це мережевий контракт розширення, тож клієнт оголошує їх для себе сам. Опубліковане розширення постачало б їх у пакеті, який імпортують обидві сторони. ### Перехоплення `tools/call` {#intercepting-toolscall} @@ -109,12 +116,18 @@ TypeError: Stamps.identifier must be a `vendor-prefix/name` string ## Використання клієнтського розширення {#using-a-client-extension} -**Клієнтське розширення** — той самий контракт з боку споживача: набір клієнтської поведінки за одним ідентифікатором. Передайте екземпляри в `Client(extensions=[...])` і викликайте інструменти як зазвичай: +**Клієнтське розширення** — той самий контракт з боку споживача: набір клієнтської поведінки за одним ідентифікатором. Сервер тут відповідає на `buy` не товаром, а квитанцією, яку треба погасити, — і лише клієнту, що оголосив розширення: -```python title="client.py" hl_lines="66-68" +```python title="server.py" hl_lines="22-25" --8<-- "docs_src/extensions/tutorial006.py" ``` +На клієнті передайте екземпляри в `Client(extensions=[...])` і викликайте інструменти як зазвичай: + +```python title="client.py" hl_lines="33-35" +--8<-- "docs_src/extensions/tutorial006_client.py" +``` + `call_tool("buy", ...)` повертає звичайний `CallToolResult`, як і будь-який інший виклик. Що змінило розширення: тепер сервер може відповісти на `buy` **формою результату** `receipt` замість остаточного результату, а `Receipts` доводить її до кінця (тут — погашаючи квитанцію наступним викликом), перш ніж `call_tool` поверне значення. У місці виклику нічого не змінюється. Приберіть розширення — і нічого з цього не існує: шлагбаум сервера відмовляє клієнту, який його не оголосив (помилка -32021), а заявлена форма від сервера, що обходить шлагбаум, не проходить перевірку — точно так, як специфікація вимагає для нерозпізнаного `resultType`. Вимкнено за замовчуванням, з обох кінців з'єднання. @@ -124,15 +137,15 @@ TypeError: Stamps.identifier must be a `vendor-prefix/name` string ```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} Успадкуйте `ClientExtension` і перевизначте лише те, що потрібно. Три види внеску, кожен із типовою реалізацією: `settings()`, `claims()` і `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" ``` * Ідентифікатор підпорядковується тій самій граматиці, що й на сервері, і перевіряється під час визначення класу. @@ -153,12 +166,18 @@ def notifications(self) -> Sequence[NotificationBinding[Any]]: ### Дієслова розширень {#extension-verbs} -Власні методи запитів розширення не потребують реєстрації на клієнті. Тип вендорського запиту успадковує `mcp.types.Request` і проходить через `client.session.send_request`, як у розділі [Обслуговування власних методів](#serving-your-own-methods). Одне доповнення: коли ключ параметрів мусить передаватися в заголовку `Mcp-Name` (специфікації розширень, як-от tasks, вимагають цього для своїх дієслів), тип запиту оголошує `name_param`: +Власні методи запитів розширення не потребують реєстрації на клієнті. Тип вендорського запиту успадковує `mcp.types.Request` і проходить через `client.session.send_request`, як у розділі [Обслуговування власних методів](#serving-your-own-methods). Візьмімо сервер, розширення якого обслуговує одне дієслово про іменоване завдання: -```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" ``` +Одне доповнення на клієнті: коли ключ параметрів мусить передаватися в заголовку `Mcp-Name` (специфікації розширень, як-от tasks, вимагають цього для своїх дієслів), тип запиту оголошує `name_param`: + +```python title="client.py" hl_lines="20-23 28-29" +--8<-- "docs_src/extensions/tutorial007_client.py" +``` + Сесія дзеркалить `params["jobId"]` у `Mcp-Name` на кожному шляху надсилання, а відсутнє значення дає гучну помилку замість того, щоб мовчки пропустити обов'язковий заголовок. ## Чого розширення не може {#what-an-extension-cannot-do} diff --git a/i18n/uk/pages/advanced/low-level-server.md b/i18n/uk/pages/advanced/low-level-server.md index 98926a31..2e9859f8 100644 --- a/i18n/uk/pages/advanced/low-level-server.md +++ b/i18n/uk/pages/advanced/low-level-server.md @@ -1,6 +1,6 @@ --- translation: - sections: [2c79b6338e09b7ac, 7edc43b3fae11314, 1086e77ce561cd7f, a3f71823df5efc31, 9fc7109f72201cae, d50fe7faead8cf68, 7bf25983df655b66, 6330e1f4c6029683, 2f1749c8c133fa1c, 8db7116fc8ddd0ee, ebc33704fbd74262, cd0e9c933350390e] + sections: [2c79b6338e09b7ac, 9d5d10a5f0405d0a, 1086e77ce561cd7f, a3f71823df5efc31, 9fc7109f72201cae, d50fe7faead8cf68, 7bf25983df655b66, 6330e1f4c6029683, 2f1749c8c133fa1c, 8db7116fc8ddd0ee, ebc33704fbd74262, 0fde3bcea081ba3a] tool: 1 --- # Низькорівневий Server {#the-low-level-server} @@ -36,18 +36,22 @@ translation: ### Спробуйте самі {#try-it} -Inspector тут не допоможе: `mcp dev` і `mcp run` приймають лише `MCPServer`. `Client`, що працює в пам'яті, до цього байдужий: низькорівневий `Server` він приймає так само, як і `MCPServer`: +`mcp dev` і `mcp run` приймають лише `MCPServer`, тож цей сервер доведеться запускати самостійно. Останній рядок `server.py` будує з нього звичайний ASGI-застосунок, а uvicorn його запускає: -```python title="main.py" +```console +uvicorn server:app --port 8000 +``` + +Спрямуйте Inspector або будь-який клієнт на `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) @@ -64,6 +68,8 @@ asyncio.run(main()) * `result.structured_content` дорівнює `None`. Високорівневий сервер загортає `-> str` у `{"result": ...}` за вас; тут ніхто не будує того, чого не побудували ви. * `list_tools` повертає схему, яку набрали **ви**, символ у символ. У високорівневій версії на кожній властивості було `"title": "Query"`, а в корені — `"title": "search_booksArguments"`: артефакти Pydantic. Тут, якщо щось є в переданих даних, — це ви його туди поклали. +У тесті uvicorn і порт не потрібні: `Client(server)` приймає низькорівневий `Server` у тому ж процесі так само, як і `MCPServer`, — саме цей підхід описано на сторінці **[Тестування](../get-started/testing.md)**. + ## Ніхто нічого не перевіряє за вас {#nothing-is-checked-for-you} `MCPServer` відхиляє хибний аргумент ще до того, як ваша функція запуститься, перевіряючи виклик за схемою, яку сам згенерував (**[Інструменти](../servers/tools.md)**). @@ -215,4 +221,4 @@ use Server.middleware to observe or wrap initialization * `add_request_handler(method, params_type, handler)` обслуговує будь-який метод. `initialize` зарезервовано. * Можливості, які оголошує `Server`, виводяться з того, які обробники ви зареєстрували. -`Client(server)` поводився з обома серверами однаково, бо вони *і є* тим самим протоколом — у цьому й увесь сенс. Наступний шар нижче — взагалі не клас: це **[Middleware](middleware.md)**. +Клієнт поводився з обома серверами однаково, бо вони *і є* тим самим протоколом — у цьому й увесь сенс. Наступний шар нижче — взагалі не клас: це **[Middleware](middleware.md)**. diff --git a/i18n/uk/pages/advanced/pagination.md b/i18n/uk/pages/advanced/pagination.md index 01888ab4..fc962221 100644 --- a/i18n/uk/pages/advanced/pagination.md +++ b/i18n/uk/pages/advanced/pagination.md @@ -1,6 +1,6 @@ --- translation: - sections: [a9aba7a026c7bd85, ed32bda7ba9ae33a, 7e64cc5646abb91f, 22a0129ee78b3c63, d875373c06d8d2f9] + sections: [a9aba7a026c7bd85, 83e2a08b9d46a398, 9fd8a0aa384b3257, 22a0129ee78b3c63, d875373c06d8d2f9] tool: 1 --- # Пагінація {#pagination} @@ -31,9 +31,13 @@ translation: ### Спробуйте самі {#try-it} -`Client(server)` під'єднується до низькорівневого `Server` у пам'яті так само, як і до `MCPServer`. +`mcp run` приймає лише `MCPServer`, тож цей сервер доведеться обслуговувати самостійно. Останній рядок `server.py` будує з `Server` звичайний ASGI-застосунок, а uvicorn його запускає: -Викличте `list_resources()` без аргументів. Повертається десять ресурсів, від `book-1` до `book-10`, а `next_cursor` — рядок `"10"`. +```console +uvicorn server:app --port 8000 +``` + +Спрямуйте будь-який клієнт (**[Клієнт](../client/index.md)** або Inspector) на `http://localhost:8000/mcp` і викличте `list_resources()` без аргументів. Повертається десять ресурсів, від `book-1` до `book-10`, а `next_cursor` — рядок `"10"`. Передайте його назад через `list_resources(cursor="10")` — і перший ресурс уже `book-11`, а новий `next_cursor` — `"20"`. @@ -43,7 +47,7 @@ translation: Кожен метод `list_*` класу `Client` (`list_tools`, `list_resources`, `list_resource_templates`, `list_prompts`) приймає іменований параметр `cursor=`. Вичерпати список зі сторінками — це один `while True`: -```python title="client.py" hl_lines="26-32" +```python title="client.py" hl_lines="9-15" --8<-- "docs_src/pagination/tutorial002.py" ``` @@ -51,7 +55,7 @@ translation: * Розширюйте список **до** того, як дивитися на `next_cursor`: на останній сторінці теж є ресурси. * `next_cursor is None` — це вихід. Усе інше йде прямо назад у `cursor=`, без змін. -Запустіть його `main()` — і він надрукує `100 resources`: десять сторінок по десять, зшитих циклом, який так і не дізнався, що сторінок було десять. +Поки uvicorn усе ще обслуговує `server.py`, запустіть `python client.py` у другому терміналі. Він надрукує `100 resources`: десять сторінок по десять, зшитих циклом, який так і не дізнався, що сторінок було десять. Це той самий цикл, який **[Клієнт](../client/index.md)** показує для кожного дієслова `list_*`, і проти сервера без сторінок він нічого не коштує: `next_cursor` дорівнює `None` вже в першій відповіді, і цикл виконується один раз. diff --git a/i18n/uk/pages/client/caching.md b/i18n/uk/pages/client/caching.md index 95ba40ec..055bd21d 100644 --- a/i18n/uk/pages/client/caching.md +++ b/i18n/uk/pages/client/caching.md @@ -1,6 +1,6 @@ --- translation: - sections: [9e7b9a1710e5aeba, b74ca4c1d2ddddee, fa8714e61bf90c5a, 04db67a886b7271c, 857690fb8f876800] + sections: [9e7b9a1710e5aeba, 66a2e9acc9101d54, d3d25aa802f5145a, 04db67a886b7271c, 857690fb8f876800] tool: 1 --- # Підказки щодо кешування {#caching-hints} @@ -30,7 +30,7 @@ translation: На низькорівневому `Server` обробники будують результати вручну, а `ttl_ms` / `cache_scope` — звичайні поля моделей результатів. Обробник, який задає їх явно, завжди перемагає мапу конструктора, поле за полем: -```python title="server.py" hl_lines="10 16" +```python title="server.py" hl_lines="11 17" --8<-- "docs_src/caching/tutorial002.py" ``` @@ -44,10 +44,24 @@ translation: У сесії 2026-07-28 `Client` дотримується підказок за вас: він має вбудований кеш відповідей, увімкнений за замовчуванням. Результат, що прийшов із `ttlMs`, зберігається, і ідентичний виклик у межах цього TTL обслуговується з кешу без звернення до сервера. Результат *без* підказки не кешується: результати без підказок отримують `CacheConfig.default_ttl_ms`, типове значення якого `0` (одразу застарілий), тож сервер, який нічого не оголошує, бачить рівно той самий трафік виклик-за-викликом, що й завжди. -```python title="client.py" hl_lines="33 35 38" +Щоб побачити це на власні очі, запустіть `server.py` з попереднього розділу через uvicorn (його останній рядок будує ASGI-застосунок). Обробник друкує рядок щоразу, коли справді виконується: + +```console +uvicorn server:app --port 8000 +``` + +```python title="client.py" hl_lines="20 23 28" --8<-- "docs_src/caching/tutorial003.py" ``` +Запустіть `python client.py` у другому терміналі. Він надрукує підказки, які містив перший результат: `ttlMs` обробника поруч із `cacheScope` з мапи: + +```text +1000 public +``` + +Решту розповідає термінал сервера: між логами запитів uvicorn рядок `tools/list served` з'являється тричі. + Чотири виклики, три отримання. Другий виклик знайшов свіжий запис і не дійшов до сервера; переведення (впровадженого) годинника за межу TTL змусило третій знову звернутися до сервера; четвертий сказав `cache_mode="refresh"`. Цей іменований аргумент є в п'яти методів із кешуванням (`list_tools`, `list_prompts`, `list_resources`, `list_resource_templates`, `read_resource`): * `"use"` (за замовчуванням) віддає свіжий запис, якщо він є, а якщо ні — звертається до сервера і зберігає результат. @@ -56,7 +70,7 @@ translation: Одне правило стоїть над `"use"`: **виклики з `meta` завжди доходять до сервера.** Запит із заданим `meta` (токен перебігу виконання, поля трасування) очікує запиту мережею, тому за `cache_mode="use"` його обробляють як `"refresh"`: читання з кешу пропускається, а отриманий результат усе одно замінює закешований запис. `"bypass"` і явний `"refresh"` поводяться як завжди. -Щоб повністю вимкнути кешування, створіть клієнт як `Client(server, cache=None)`: кожен виклик знову стає зверненням до сервера, а `cache_mode`, хоч і приймається далі, нічого не робить. +Щоб повністю вимкнути кешування, передайте `cache=None` під час створення `Client`: кожен виклик знову стає зверненням до сервера, а `cache_mode`, хоч і приймається далі, нічого не робить. Область також дотримується автоматично: записи `"private"` прив'язані до *розділу* (partition) кешу (нижче), тоді як записи `"public"` можуть за бажанням ділитися ширше. А **сповіщення перемагають TTL** для конкретних записів, які вони називають: сповіщення `list_changed` витісняє відповідний закешований список, а `resources/updated` витісняє закешоване читання, збережене рівно під його URI, хоч би якими свіжими вони були. На з'єднанні 2026-07-28 ці сповіщення надходять потоком `subscriptions/listen`, який відкривають через `client.listen(...)`, і витіснення завершується до того, як ваш спостерігач побачить подію; докладніше — на сторінці **[Підписки](subscriptions.md)**. diff --git a/i18n/uk/pages/client/callbacks.md b/i18n/uk/pages/client/callbacks.md index 73dc5328..912a32a0 100644 --- a/i18n/uk/pages/client/callbacks.md +++ b/i18n/uk/pages/client/callbacks.md @@ -1,6 +1,6 @@ --- translation: - sections: [adf3c545b5be46b6, 916cd3ab1c03f461, e9be7a8d0eb0a456, 565890a636288ecf, 6af7e49db9129ec3, 06b0238c174186af, 90c6043be435fcb0] + sections: [adf3c545b5be46b6, 916cd3ab1c03f461, 32ef568335dd95a7, 565890a636288ecf, 6af7e49db9129ec3, 06b0238c174186af, 0abc5ea5cb7ff6b3] tool: 1 --- # Колбеки клієнта {#client-callbacks} @@ -63,7 +63,7 @@ result.content # [TextContent(type='text', text='Card issued to Ada Lovelace.') `mode="legacy"` у виклику `Client(...)` стоїть не просто так. За замовчуванням `Client(...)` узгоджує сучасний шлях протоколу, а на ньому немає зворотного каналу (back-channel) для запитів від сервера до клієнта: `ctx.elicit` завершується помилкою ще до того, як запуститься колбек. Вирішує це не транспорт, а узгоджений - протокол — однаково і в пам'яті, і за URL. Фіксуйте `mode="legacy"` щоразу, коли клієнт має + протокол. Фіксуйте `mode="legacy"` щоразу, коли клієнт має відповідати на такий запит; так робить кожен тест за цією сторінкою. Докладніше — на сторінці **[Версії протоколу](../protocol-versions.md)**. У сесії 2026-07-28 колбек не зникає, він просто отримує дані інакше: коли інструмент повертає @@ -151,4 +151,4 @@ result.structured_content # {'result': ['elicitation']} * `sampling_callback` і `list_roots_callback` працюють так само, але обслуговують застарілі можливості; сучасні сервери натомість використовують багатораундові запити. * `logging_callback` і `message_handler` отримують сповіщення. Вони нічого не оголошують. -Перший аргумент `Client(...)` — об'єкт транспорту. Усі його різновиди описано на сторінці **[Транспорти клієнта](transports.md)**. +Перший аргумент `Client(...)` визначає транспорт. Усі його різновиди описано на сторінці **[Транспорти клієнта](transports.md)**. diff --git a/i18n/uk/pages/client/index.md b/i18n/uk/pages/client/index.md index 91a41062..4fc9f8fa 100644 --- a/i18n/uk/pages/client/index.md +++ b/i18n/uk/pages/client/index.md @@ -1,6 +1,6 @@ --- translation: - sections: [ebef1e7a0df854f4, 8355cfaf1f76c9d5, 8e79141fc2985342, 46bdb07c7537e8a5, 80ce41579825a6fa, 5f0fa90494de8f65, 83d10514eaa62fa5, 9190555aa39a5d28, 84a4c9d8bf14dddb, 927d71cf40b58c30] + sections: [ebef1e7a0df854f4, 7e16449f66e7dfd6, eeb0682f7d2a1079, 5713f0196a34e6e7, 0e844597859e4248, 3a97d9195ddcc92e, 1da08c483e59c141, 84702cc6e0a1fd42, 8dee7a31c86ffc5c, 83a5bce168ef23d7] tool: 1 --- # Клієнт {#the-client} @@ -11,13 +11,23 @@ translation: ## Перший клієнт {#your-first-client} -```python title="client.py" hl_lines="14-18" +Клієнтові потрібен сервер, з яким говорити. До цього Bookshop під'єднується кожен приклад на цій сторінці. Збережіть його як `server.py` і залиште працювати через HTTP: + +```python title="server.py" --8<-- "docs_src/client/tutorial001.py" ``` -Сервер угорі потрібен лише для того, щоб було до чого під'єднатися. Клієнт — це п'ять виділених рядків. +```console +uv run mcp run server.py --transport streamable-http +``` -* `Client(mcp)` отримує **сам об'єкт сервера**. Це транспорт у пам'яті: без підпроцесу, без порту, без HTTP. Саме так під'єднується кожен приклад на цій сторінці й кожен тест, який ви напишете. +Тепер сервер доступний за адресою `http://localhost:8000/mcp`. Клієнт — окрема програма. Збережіть його як `client.py` і запустіть `python client.py` у другому терміналі: + +```python title="client.py" hl_lines="7-11" +--8<-- "docs_src/client/tutorial001_client.py" +``` + +* `Client("http://localhost:8000/mcp")` отримує **URL**, тож під'єднується через Streamable HTTP до сервера, який ви щойно запустили. * `async with` — це **життєвий цикл**. Вхід у блок під'єднує й узгоджує параметри; вихід — від'єднує. Пари `connect()` / `close()` немає, а `Client` не можна використати повторно після завершення блоку. * Усередині блоку відомості про з'єднання вже доступні як звичайні властивості. @@ -25,10 +35,10 @@ translation: `Client` приймає один позиційний аргумент і визначає транспорт за його типом: -* Екземпляр `MCPServer` (або низькорівневого `Server`): під'єднання **в межах процесу**. -* Рядок з URL (`Client("http://localhost:8000/mcp")`): Streamable HTTP, шлях для робочого розгортання. -* `StdioServerParameters`: команда, яку буде запущено як **підпроцес**; спілкування з ним іде через його stdin і stdout. +* Рядок з URL (`Client("http://localhost:8000/mcp")`): Streamable HTTP, транспорт для робочого розгортання. +* `StdioServerParameters`: команда, яку буде запущено як локальний **підпроцес**; спілкування з ним іде через його stdin і stdout. * **Транспорт**: будь-що, що можна використати як `async with ... as (read, write)`, наприклад `streamable_http_client(url, http_client=...)` навколо вашого власного HTTP-клієнта. +* Екземпляр `MCPServer` (або низькорівневого `Server`): під'єднання **в межах процесу**, без підпроцесу й без порту. Це для тестів, і на ньому побудована сторінка **[Тестування](../get-started/testing.md)**. Усе інше на цій сторінці однакове для всіх чотирьох. Заголовки, підпроцеси, тайм-аути та протокол `Transport` мають власну сторінку: **[Транспорти клієнта](transports.md)**. @@ -49,11 +59,11 @@ translation: ## Перелік інструментів {#listing-tools} -```python title="client.py" hl_lines="15-20" +```python title="client.py" hl_lines="8-13" --8<-- "docs_src/client/tutorial002.py" ``` -`list_tools()` повертає `ListToolsResult`; інструменти лежать у `.tools`. Кожен із них — повне означення, яке хост передав би моделі: +`list_tools()` повертає `ListToolsResult`; інструменти лежать у `.tools`. Кожен із них — повне означення, яке хост передав би моделі. Ось перший: ```python tool.name # 'search_books' @@ -77,6 +87,8 @@ tool.description # 'Search the catalog by title or author.' Ця схема — усе, що потрібно UI, щоб показати форму аргументів, і все, що потрібно моделі, щоб сформувати коректні аргументи. +Другий інструмент, `lookup_book`, зареєстровано без `title=`, тож його `tool.title` дорівнює `None`. + !!! tip `title` необов'язковий, тож UI, що показує інструменти людині, має обирати: `title`, якщо він є, і `name`, якщо немає. `from mcp.shared.metadata_utils import get_display_name` робить саме це — @@ -86,7 +98,7 @@ tool.description # 'Search the catalog by title or author.' `call_tool(name, arguments)` запускає інструмент і повертає `CallToolResult`. -```python title="client.py" hl_lines="27-34" +```python title="client.py" hl_lines="9-16" --8<-- "docs_src/client/tutorial003.py" ``` @@ -142,7 +154,7 @@ result.is_error # False Дії з ресурсами йдуть парами: два способи перелічити, один спосіб прочитати. -```python title="client.py" hl_lines="22-31" +```python title="client.py" hl_lines="9-18" --8<-- "docs_src/client/tutorial004.py" ``` @@ -156,7 +168,7 @@ result.is_error # False ## Промпти {#prompts} -```python title="client.py" hl_lines="15-20" +```python title="client.py" hl_lines="8-13" --8<-- "docs_src/client/tutorial005.py" ``` @@ -181,7 +193,7 @@ message.content # TextContent(type='text', text='Recommend one poetry book from Сервер з обробником автодоповнення може доповнювати аргументи промптів і шаблонів ресурсів, поки користувач друкує. -```python title="client.py" hl_lines="27-31" +```python title="client.py" hl_lines="9-13" --8<-- "docs_src/client/tutorial006.py" ``` @@ -194,21 +206,21 @@ message.content # TextContent(type='text', text='Recommend one poetry book from Кожен метод `list_*` приймає іменований аргумент `cursor=`, а кожен результат містить `next_cursor`. Коли `next_cursor` дорівнює `None`, у вас є все. -```python title="client.py" hl_lines="22-30" +```python title="client.py" hl_lines="7-15" --8<-- "docs_src/client/tutorial007.py" ``` -Цей цикл коректний для будь-якого сервера. `MCPServer` повертає все однією сторінкою, тож `next_cursor` дорівнює `None` і цикл виконується один раз — саме тому більшість коду його ніколи не пише. Сервери, що справді розбивають результати на сторінки, і правила, яким підкоряються курсори, — на сторінці **[Пагінація](../advanced/pagination.md)**. +`list_all_tools` коректна для будь-якого сервера. `MCPServer` повертає все однією сторінкою, тож `next_cursor` дорівнює `None` і цикл виконується один раз — саме тому більшість коду його ніколи не пише. Сервери, що справді розбивають результати на сторінки, і правила, яким підкоряються курсори, — на сторінці **[Пагінація](../advanced/pagination.md)**. ## У тестах {#in-tests} -`Client(mcp)` без процесу й без порту — це вже тестова обв'язка для вашого сервера. +Кожен `client.py` на цій сторінці звертався до `server.py` через HTTP. У тесті мережу оминають і передають у `Client` сам об'єкт сервера: `from server import mcp`, потім `Client(mcp)`. Без процесу, без порту, а всі методи вище працюють так само. -Саме для цього є один прапорець конструктора: `Client(mcp, raise_exceptions=True)`. Він діє лише на з'єднаннях у пам'яті, а пояснює його й будує навколо нього весь підхід сторінка **[Тестування](../get-started/testing.md)**. +Саме для цього є один прапорець конструктора: `Client(mcp, raise_exceptions=True)`. Він діє лише на з'єднаннях у межах процесу, а пояснює його й будує навколо нього весь підхід сторінка **[Тестування](../get-started/testing.md)**. ## Підсумки {#recap} -* `Client(x)` під'єднується в пам'яті до об'єкта сервера, через Streamable HTTP — до рядка з URL і через транспорт — до всього іншого. +* `Client(x)` під'єднується через Streamable HTTP до рядка з URL, запускає підпроцес для `StdioServerParameters`, входить у транспорт напряму, а в тестах приймає сам об'єкт сервера. * `async with` — це весь життєвий цикл. Усередині нього `server_capabilities` і `protocol_version` уже заповнені; `server_info` та `instructions` — теж, якщо сервер їх надає. * `list_tools()` дає `name`, `title`, `description` та `input_schema` кожного інструмента. * `call_tool()` повертає `content` для моделі, `structured_content` для вашого коду та `is_error`. Інструмент, що викидає виняток, — це результат, а не виняток. diff --git a/i18n/uk/pages/client/oauth-clients.md b/i18n/uk/pages/client/oauth-clients.md index e39d2184..bf3e8726 100644 --- a/i18n/uk/pages/client/oauth-clients.md +++ b/i18n/uk/pages/client/oauth-clients.md @@ -1,6 +1,6 @@ --- translation: - sections: [c6899d3892bd9fa0, 79372cff3cc48a88, 63878d29e87c3e73, 13175843d3588af4, e7e2b9fd516f77de, 758f06399b513c1f, a05d7278487d610b] + sections: [c6899d3892bd9fa0, 79372cff3cc48a88, c2dae1ebe2ebd543, 13175843d3588af4, df06056fb16b3846, 758f06399b513c1f, a05d7278487d610b] tool: 1 --- # OAuth-клієнти {#oauth-clients} @@ -81,18 +81,20 @@ translation: Коли `Client` уперше надсилає запит, сервер відповідає `401`. Далі справу бере на себе провайдер: -1. **Виявлення.** Він читає заголовок `WWW-Authenticate`, завантажує Protected Resource Metadata сервера з `/.well-known/oauth-protected-resource`, дізнається, який сервер авторизації захищає цей ресурс, і завантажує метадані *того* сервера. +1. **Виявлення.** Він читає заголовок `WWW-Authenticate`, завантажує Protected Resource Metadata сервера з `/.well-known/oauth-protected-resource`, дізнається, який сервер авторизації захищає цей ресурс, і завантажує метадані *того* сервера. (Старіший сервер, що не публікує метаданих ресурсу, натомість запитують про метадані сервера авторизації за його власним origin.) У будь-якому разі метадані мають називати своїм `issuer` той сервер, для якого їх завантажено; усе інше відхиляється. 2. **Реєстрація.** У сховищі порожньо? Він динамічно реєструє вас із вашими `OAuthClientMetadata` і зберігає результат. 3. **Авторизація.** Він генерує пару PKCE і `state`, будує URL авторизації, очікує ваш `redirect_handler`, а потім — ваш `callback_handler`, щоб отримати код. 4. **Обмін.** Він міняє код на `OAuthToken`, зберігає його й повторює ваш початковий запит із `Authorization: Bearer ...`. Після цього все тихо. Токени беруться зі сховища, прострочений токен доступу оновлюється за допомогою токена оновлення, і лише коли ніщо з цього не спрацьовує, провайдер запускає потік знову. +Для всіх цих запитів діє одне транспортне правило: як і MCP-запит, усередині якого вони виконуються, вони переходять за перенаправленням лише тоді, коли воно лишається на тому самому origin і зберігає метод (скажімо, 307/308 на кінцеву скісну риску), а будь-яке інше перенаправлення трактують так, ніби цей URL не відповідає. + Нічого з цього ви не писали. Лишаються ще два іменовані аргументи (`client_metadata_url` і `validate_resource_url`), і цьому файлу не потрібен жоден. Знати варто про `client_metadata_url`; йому присвячено окремий розділ нижче. ### Спробуйте самі {#try-it} -Більшість прикладів у цій документації можна перевірити за допомогою `Client(server)` у пам'яті. Цей — ні: уся суть потоку в HTTP-відповіді `401`, а між клієнтом у пам'яті та його сервером HTTP немає. +`Client(server)` у пам'яті, яким користуються ваші тести, тут не допоможе: уся суть потоку в HTTP-відповіді `401`, а між клієнтом у пам'яті та його сервером HTTP немає. У репозиторії є жива версія. `examples/servers/simple-auth/` запускає окремий сервер авторизації та захищений MCP-сервер; `examples/clients/simple-auth-client/` — це клієнт із цієї сторінки, що виріс у невеликий CLI. У його README є дві команди: запустіть сервери, запустіть клієнт проти них — і спостерігайте, як проходять усі чотири кроки. @@ -110,13 +112,14 @@ URL має бути HTTPS із некореневим шляхом; усе ін `ClientCredentialsOAuthProvider` — це той самий `httpx2.Auth`, тільки без людини: -```python title="client.py" hl_lines="4 27-33" +```python title="client.py" hl_lines="4 27-34" --8<-- "docs_src/oauth_clients/tutorial002.py" ``` Що змінилося: * Немає `OAuthClientMetadata`, немає обробників. Ви передаєте `client_id` і `client_secret`; провайдер будує навколо них мінімальну реєстрацію `client_credentials` і повністю пропускає динамічну реєстрацію. +* `issuer` називає сервер авторизації, що видав ці облікові дані; використовуйте значення `issuer`, яке повертає його документ `/.well-known/oauth-authorization-server`. Виявлення й далі виконується, як описано вище, але запити на токен будуються лише з метаданих *цього* видавця; якщо MCP-сервер указує кудись інде, потік натомість зупиняється з `OAuthFlowError`. Пропускати його — застаріла практика, і у версії 3.0 він стане обов'язковим (див. **[Застарілі можливості](../deprecated.md#deprecated-sdk-helpers)**); доти провайдер попереджає й використовує той сервер авторизації, який знайде виявлення. * `scope` — це рядок, розділений пробілами: такий формат OAuth використовує в переданих даних. * Усе далі ідентичне: той самий `TokenStorage`, той самий `httpx2.AsyncClient(auth=...)`, той самий `streamable_http_client`. @@ -129,7 +132,7 @@ URL має бути HTTPS із некореневим шляхом; усе ін Ще один провайдер живе в `mcp.client.auth.extensions.client_credentials`: **`PrivateKeyJWTOAuthProvider`** — для клієнтів, які автентифікуються за допомогою JWT замість спільного секрету (`private_key_jwt`, варіант із парою ключів та workload identity). Він дотримується - тієї ж схеми: створіть екземпляр і передайте в `auth=`. У тому ж модулі є + тієї ж схеми: створіть екземпляр (він приймає той самий необов'язковий `issuer`) і передайте в `auth=`. У тому ж модулі є `SignedJWTParameters` і `static_assertion_provider` — два допоміжні засоби, що будують для нього assertion. Є ще одна ситуація без людини: клієнт належить підприємству, де провайдер ідентичності, а не користувач, вирішує, до яких MCP-серверів він може звертатися. Це інший грант із власною моделлю довіри та власною сторінкою — **[Твердження про ідентичність](identity-assertion.md)**. diff --git a/i18n/uk/pages/client/session-groups.md b/i18n/uk/pages/client/session-groups.md index db219633..4e4f14e9 100644 --- a/i18n/uk/pages/client/session-groups.md +++ b/i18n/uk/pages/client/session-groups.md @@ -1,6 +1,6 @@ --- translation: - sections: [09c857a25a9dc37a, 43bc6a76a243a50e, 0a716022a88768df, 4b7f78042bfcfff7, c112662e61b03315, 58974ba1f489a8b4, d18adbdbb835ea73] + sections: [09c857a25a9dc37a, 43bc6a76a243a50e, 0a716022a88768df, 4b7f78042bfcfff7, c112662e61b03315, 58974ba1f489a8b4, ed4d17e894864056] tool: 1 --- # Групи сесій {#session-groups} @@ -78,7 +78,7 @@ translation: ## Підсумки {#recap} * `ClientSessionGroup` тримає багато з'єднань із серверами й зводить їхні інструменти, ресурси й промпти в один `dict` кожного виду. -* `connect_to_server(params)` для кожного сервера. Він приймає параметри транспорту й ніколи — об'єкт сервера чи URL, як `Client`. +* `connect_to_server(params)` для кожного сервера. Він приймає параметри транспорту й ніколи — URL чи `Transport`, які приймає `Client`. * `group.call_tool(name, arguments)` сам спрямовує виклик на сервер-власник. * Імена мають бути унікальними в межах усієї групи; два сервери з інструментом `search` самі по собі співіснувати не можуть. * `component_name_hook=` переписує кожне зареєстроване ім'я. Змінюється ключ словника, а не ім'я в переданих даних. diff --git a/i18n/uk/pages/client/transports.md b/i18n/uk/pages/client/transports.md index 97ed0544..17aab3a8 100644 --- a/i18n/uk/pages/client/transports.md +++ b/i18n/uk/pages/client/transports.md @@ -1,6 +1,6 @@ --- translation: - sections: [9cac816674181eb0, 0700f337babcd4dd, 2bde0dd58cdf00f5, 40b4916d82eaf1d4, 3d0832f39b0d7059, dfa4446556badef0, 5bd93be2ab2ecb9c] + sections: [9cac816674181eb0, 7c157764133fea1f, 40b4916d82eaf1d4, 10d151f2cc75317f, 3d0832f39b0d7059, 92742ba36533633d, 0aeca6145e7bd302] tool: 1 --- # Транспорти клієнта {#client-transports} @@ -11,30 +11,15 @@ translation: *Серверний* бік кожного з них (що робить `mcp.run()` і що ви розгортаєте) описано на сторінці **[Запуск сервера](../run/index.md)**. -## У пам'яті {#in-memory} - -Передайте сам об'єкт сервера: - -```python title="client.py" hl_lines="14" ---8<-- "docs_src/client_transports/tutorial001.py" -``` - -Жодного підпроцесу, жодного порту, жодних байтів у мережі. Клієнт і сервер — це два об'єкти в одному процесі, а виклик усе одно проходить через справжній протокольний рівень: `search_books` перелічується, валідується й викликається точнісінько так само, як це було б через HTTP. - -Тож це одразу дві речі: - -* **Тестовий стенд.** Кожен приклад у цій документації перевіряється саме так, а сторінка **[Тестування](../get-started/testing.md)** будує довкола цього весь підхід. -* **API для вбудовування.** Застосунку, який сам створює сервер, не потрібен мережевий перехід, щоб викликати його інструменти. - ## Streamable HTTP {#streamable-http} -Передайте рядок з URL — і отримаєте **Streamable HTTP**, транспорт, за яким розгортають сервер: +Передайте рядок з URL — і отримаєте **Streamable HTTP**, транспорт, за яким розгортають сервер і до якого варто звертатися насамперед: ```python title="client.py" hl_lines="5" --8<-- "docs_src/client_transports/tutorial002.py" ``` -Оце й увесь продакшен-клієнт. `Client` сам загортає URL у `streamable_http_client(...)` поверх `httpx2.AsyncClient`, налаштованого так, як потрібно MCP: `follow_redirects=True`, 30-секундний тайм-аут на connect/write/pool і 300-секундний тайм-аут на читання, бо сервер може тримати потік відповіді відкритим. +Оце й увесь продакшен-клієнт. `Client` сам загортає URL у `streamable_http_client(...)` поверх `httpx2.AsyncClient`, налаштованого так, як потрібно MCP: 30-секундний тайм-аут на connect/write/pool і 300-секундний тайм-аут на читання, бо сервер може тримати потік відповіді відкритим. !!! check Створений `Client` ще **не** під'єднаний. Конструктор лише обирає транспорт; @@ -50,7 +35,7 @@ translation: Щойно знадобиться заголовок `Authorization`, cookie, проксі, mTLS чи інший тайм-аут — створіть `httpx2.AsyncClient` самостійно й передайте його в `streamable_http_client`: -```python title="client.py" hl_lines="8-14" +```python title="client.py" hl_lines="8-13" --8<-- "docs_src/client_transports/tutorial003.py" ``` @@ -80,9 +65,30 @@ translation: !!! info `httpx2` зберігає знайомий API `httpx`, тож якщо ви знаєте `httpx`, то вже вмієте робити тут автентифікацію, проксі, хуки подій, повторні спроби й обмеження з'єднань. SDK нічого не додає зверху й нічого не - забирає. Саме сюди під'єднується й OAuth: + забирає, окрім [обробки перенаправлень](#redirects). Саме сюди під'єднується й OAuth: `httpx2.AsyncClient(auth=OAuthClientProvider(...))`. Увесь цей процес описано на сторінці **[OAuth-клієнти](oauth-clients.md)**. +### Перенаправлення {#redirects} + +Транспорт під'єднується до URL, який ви йому передали, і лише до цього origin. + +* Перенаправлення `307`/`308`, що лишається в межах тієї самої схеми, хоста й порту, виконується; так само й `http://` → `https://` на тому самому хості. Це покриває звичне перенаправлення з кінцевою скісною рискою `/mcp` → `/mcp/`. +* Перенаправлення будь-куди інде **не** виконується. Виклик завершується помилкою: + + ```text + MCPError: Redirect to https://other.example.com/mcp not followed; use that URL as the endpoint if it is the intended server + ``` + + Якщо цей URL — сервер, який ви мали на увазі, впишіть його в конфігурацію. Якщо ні — сервер або проксі перед ним налаштовано неправильно. + +Це стосується будь-якого `httpx2.AsyncClient`, який ви передаєте: його налаштування `follow_redirects` для MCP-запитів не враховується — в жоден бік. OAuth-провайдери SDK застосовують те саме правило до власних запитів. + +!!! tip + `Redirect to http://… not followed: it would downgrade this HTTPS endpoint to plain HTTP` означає, що + сервер стоїть за проксі з термінацією TLS, про який не знає, і видає перенаправлення на `http://`. + Це виправляють на сервері (**[Розгортання та масштабування](../run/deploy.md#behind-a-tls-terminating-proxy)**) + або використанням точного URL `https://…/`, який підказує повідомлення. + ## stdio {#stdio} Сервер **stdio** — це підпроцес. Клієнт запускає його, пише JSON-RPC в його stdin і читає JSON-RPC з його stdout. Саме так десктопний хост запускає сервер на вашій машині: хост і *є* цим кодом плюс UI, а сторінка **[Під'єднання до справжнього хоста](../get-started/real-host.md)** показує ті самі стосунки з боку хоста — як конфігураційний файл. @@ -105,6 +111,18 @@ stderr дочірнього процесу йде у ваш. Щоб спряму Сервер, якому потрібен ключ API, там його не знайде. Передайте його явно через `env=`; ці змінні накладаються поверх списку дозволених. Саме це й робить `BOOKSHOP_API_KEY` вище. +## У пам'яті {#in-memory} + +У тесті нема чого розгортати й нема чого запускати. Передайте сам об'єкт сервера: + +```python hl_lines="14" +--8<-- "docs_src/client_transports/tutorial001.py" +``` + +Жодного підпроцесу, жодного порту, жодних байтів у мережі. Клієнт і сервер — це два об'єкти в одному процесі, а виклик усе одно проходить через справжній протокольний рівень: `search_books` перелічується, валідується й викликається точнісінько так само, як це було б через HTTP. Сторінка **[Тестування](../get-started/testing.md)** будує довкола цього весь підхід. + +Ця сама форма водночас слугує API для вбудовування: застосунок, який сам створює сервер, може викликати його інструменти без мережевого переходу. + ## SSE {#sse} `sse_client(url)` з `mcp.client.sse` — це HTTP-транспорт, на зміну якому прийшов Streamable HTTP. Загортайте його так само, `Client(sse_client("http://localhost:8000/sse"))`, щоб говорити із сервером, який досі ним користується, — і не будуйте на ньому нічого нового. @@ -113,15 +131,16 @@ stderr дочірнього процесу йде у ваш. Щоб спряму Для `Client` усе перелічене вище — одне й те саме. -**Транспорт** — це будь-який асинхронний контекстний менеджер, що повертає пару потоків повідомлень `(read, write)`: формально — протокол `Transport` у `mcp.client`. `Client` розв'язує свій аргумент за типом: об'єкт сервера під'єднується в межах процесу, `str` стає `streamable_http_client(url)`, `StdioServerParameters` стає `stdio_client(params)`, а в будь-що інше він входить безпосередньо як у транспорт. Саме завдяки останньому правилу `stdio_client(...)`, `streamable_http_client(...)` і `sse_client(...)` стають на одне й те саме місце — і саме тому можна написати власний. +**Транспорт** — це будь-який асинхронний контекстний менеджер, що повертає пару потоків повідомлень `(read, write)`: формально — протокол `Transport` у `mcp.client`. `Client` розв'язує свій аргумент за типом: `str` стає `streamable_http_client(url)`, `StdioServerParameters` стає `stdio_client(params)`, об'єкт сервера під'єднується в межах процесу, а в будь-що інше він входить безпосередньо як у транспорт. Саме завдяки останньому правилу `stdio_client(...)`, `streamable_http_client(...)` і `sse_client(...)` стають на одне й те саме місце — і саме тому можна написати власний. ## Підсумки {#recap} -* `Client(mcp)` (об'єкт сервера) під'єднується в пам'яті. Використовуйте для тестів і для вбудовування. * `Client("http://.../mcp")` (URL) під'єднується через Streamable HTTP, продакшен-транспорт. * Заголовки, автентифікація, проксі й тайм-аути належать `httpx2.AsyncClient`, який ви передаєте в `streamable_http_client(url, http_client=...)`. Іменованого аргументу `headers=` немає. +* Перенаправлення виконуються лише в межах власного origin цього URL (`307`/`308` із кінцевою скісною рискою), плюс `http`→`https` на тому самому хості. Усе інше завершується помилкою `Redirect to … not followed`; пропишіть у конфігурації кінцевий URL. * stdio — це `Client(StdioServerParameters(...))`. Загортайте його в `stdio_client(...)` самостійно лише для того, щоб перенаправити stderr дочірнього процесу. * Підпроцес отримує середовище зі списку дозволених, а не ваше; `env=` його доповнює. +* `Client(mcp)` (об'єкт сервера) під'єднується в пам'яті. Використовуйте в тестах або щоб вбудувати сервер у застосунок, який його створив. * Транспорт — це будь-що, з чим можна зробити `async with x as (read, write)`. Усе, що не є об'єктом сервера, URL чи `StdioServerParameters`, `Client` передає прямо цьому протоколу. * Створення `Client` обирає транспорт. `async with` його відкриває. diff --git a/i18n/uk/pages/deprecated.md b/i18n/uk/pages/deprecated.md index c079c516..08e50718 100644 --- a/i18n/uk/pages/deprecated.md +++ b/i18n/uk/pages/deprecated.md @@ -1,11 +1,11 @@ --- translation: - sections: [490237e61c3a7a44, 01262a123ad9501d, 429db5b574a2ac08, e2d0d273fbd2d74b, 64ab0331e868f3d4, 6c8878ce2d1f6d56, 4068f23e371bf0b3, eaef75b8725bc931] + sections: [4c1dea378b2b1bf7, 01262a123ad9501d, 429db5b574a2ac08, e2d0d273fbd2d74b, 64ab0331e868f3d4, 6c8878ce2d1f6d56, c3d1099701156881, e185a1b8e53669f6] tool: 1 --- # Застарілі можливості {#deprecated-features} -Специфікація 2026-07-28 виводить з ужитку п'ять речей. SDK і далі реалізує кожну з них, і кожна тепер супроводжується **попередженням про застарілість**. Один допоміжний метод SDK оголошено застарілим окремо, і його наведено [наприкінці сторінки](#deprecated-sdk-helpers). +Специфікація 2026-07-28 виводить з ужитку п'ять речей. SDK і далі реалізує кожну з них, і кожна тепер супроводжується **попередженням про застарілість**. Кілька речей оголошено застарілими на рівні самого SDK, незалежно від специфікації; їх наведено [наприкінці сторінки](#deprecated-sdk-helpers). Таблиця нижче називає кожну застарілу можливість, пояснює, чому вона зникає, і вказує заміну, на яку варто спиратися. @@ -136,13 +136,15 @@ warnings.filterwarnings("ignore", category=MCPDeprecationWarning) Один рядок конфігурації pytest — і застарілий виклик більше ніколи не прокрадеться назад у вашу кодову базу, не проваливши тест. -## Застарілі допоміжні методи SDK {#deprecated-sdk-helpers} +## Застарілі допоміжні засоби SDK {#deprecated-sdk-helpers} -Це не зміни специфікації, а лише внутрішні частини SDK, для яких є краща заміна. Вони попереджають тим самим `MCPDeprecationWarning` і будуть вилучені у версії 3.0. +Це не зміни специфікації, а лише способи використання SDK, для яких є краща заміна. Вони попереджають тим самим `MCPDeprecationWarning`, а версія 3.0 вилучає стару форму. | Застаріле | Що робити натомість | |---|---| | `FuncMetadata.call_fn_with_arg_validation()` | `FuncMetadata.validate_arguments()`, а потім `FuncMetadata.call_fn()`. Його викликав лише код, що працює з `FuncMetadata` безпосередньо (скажімо, власний підклас `Tool`). | +| `AuthSettings(resource_server_url=...)` без `validate_token_resource=` | Задайте його: з `True` сервер відхиляє bearer-токени, про які ваш верифікатор не повідомляє, що їх видано для `resource_server_url`; `False` означає, що верифікатор сам перевіряє аудиторію токена (див. **[Авторизація](run/authorization.md#a-token-verifier)**). Незадане значення поводиться як `False`; у версії 3.0 `True` стане типовим значенням щоразу, коли задано `resource_server_url`. | +| `ClientCredentialsOAuthProvider(...)` або `PrivateKeyJWTOAuthProvider(...)` без `issuer=` | Передайте `issuer=` з адресою сервера авторизації, який видав облікові дані (див. **[Написання OAuth-клієнтів](client/oauth-clients.md#machine-to-machine)**). Без нього MCP-сервер вирішує, який сервер авторизації їх отримає; у версії 3.0 цей іменований аргумент стане обов'язковим. | ## Підсумки {#recap} @@ -151,7 +153,7 @@ warnings.filterwarnings("ignore", category=MCPDeprecationWarning) * Застарілість має рекомендаційний характер: жодних змін у переданих даних, усе й далі працює із сесіями до 2026, а ви отримуєте помітне попередження `MCPDeprecationWarning` (це `UserWarning`, тож воно ввімкнене за замовчуванням). * Семплювання й кореневі каталоги додатково потребують зворотного каналу, якого сесія 2026-07-28 не має. На сучасному з'єднанні вони попереджають, а потім викидають виняток. * `warnings.filterwarnings("ignore", category=MCPDeprecationWarning)` приглушує всю категорію; `"error::mcp.MCPDeprecationWarning"` у pytest перетворює її на провал тесту. -* Один допоміжний метод SDK, `FuncMetadata.call_fn_with_arg_validation()`, оголошено застарілим окремо — його вилучать у версії 3.0. +* [Застарілі засоби на рівні SDK](#deprecated-sdk-helpers) підпорядковуються тому самому правилу: зараз вони попереджають, а версія 3.0 відкидає стару форму. * Новий код не варто будувати на жодній із цих можливостей. Усі інші сторінки цієї документації навчають чинного API. diff --git a/i18n/uk/pages/get-started/first-steps.md b/i18n/uk/pages/get-started/first-steps.md index 2b711b43..4c4fb6fc 100644 --- a/i18n/uk/pages/get-started/first-steps.md +++ b/i18n/uk/pages/get-started/first-steps.md @@ -1,6 +1,6 @@ --- translation: - sections: [0d6c05bcbf836bf3, 59a7b14eeefc68c1, 7114d8d6daba203f, e8bbb56a98ba7bc9, 5138010f6159901c, f78da7c7c363d4c6, 220a939cab348686] + sections: [0d6c05bcbf836bf3, 9a78b5f6b44b18ab, 7114d8d6daba203f, e8bbb56a98ba7bc9, bfd2fd1153e71dac, 1615a994ef071fdd, 65c599fae991f245] tool: 1 --- # Перші кроки {#first-steps} @@ -17,7 +17,7 @@ translation: * **Клієнт** живе всередині хоста й говорить мовою MCP. Хост запускає по одному клієнту на кожен сервер, до якого під'єднаний. * **Сервер** — це те, що ви створюєте за допомогою цього SDK. Він надає речі клієнтам. Він ніколи не говорить із моделлю напряму. -Ви пишете сервер. Хости — це чийсь інший продукт. SDK також дає вам `Client`. Ним ви тестуватимете свої сервери, і він з'явиться далі на цій сторінці. +Ви пишете сервер. Хости — це чийсь інший продукт. SDK також дає вам `Client` — той самий клас, яким хост звертався б до сервера за URL або запускав би його як підпроцес. Він з'явиться далі на цій сторінці, і саме ним ви тестуватимете свої сервери. ## Три примітиви {#the-three-primitives} @@ -83,22 +83,20 @@ Inspector запустив ваш сервер через **stdio**, один і Коли клієнт під'єднується, сервер оголошує свої **можливості**: на які сімейства запитів він відповідатиме. Клієнт використовує це оголошення, щоб вирішити, про що взагалі просити. Ви його не писали; `MCPServer` оголошує його за вас. -Подивіться самі. `Client` з SDK приймає об'єкт сервера напряму й під'єднується до нього **в пам'яті** (без підпроцесу, без порту): +Подивіться самі. Залиште `server.py` працювати через HTTP в одному терміналі: -```python -import asyncio +```console +uv run mcp run server.py --transport streamable-http +``` -from mcp import Client +а з іншого спрямуйте на нього клієнта: -from server import mcp +```python title="client.py" hl_lines="7-8" +--8<-- "docs_src/first_steps/tutorial001_client.py" +``` - -async def main() -> None: - async with Client(mcp) as client: - print(client.server_capabilities.model_dump(exclude_none=True)) - - -asyncio.run(main()) +```console +python client.py ``` ```text @@ -118,8 +116,9 @@ asyncio.run(main()) Зверніть увагу на те, чого там немає. `completions` (автодоповнення аргументів для шаблонів ресурсів і промптів) потребує обробника, який пишете ви; у цього сервера його немає, тож можливість відсутня, і коректний клієнт про неї не проситиме. Це правило для всього необов'язкового: зареєструйте річ — і можливість з'явиться; **[Автодоповнення](../servers/completions.md)** це доводить. !!! info - `Client(mcp)` — той самий клієнт у пам'яті, яким протестовано кожен приклад у цій документації, і - саме ним ви тестуватимете свої. Йому присвячено цілу сторінку: **[Тестування](testing.md)**. + Цей `client.py` — уже повноцінний MCP-клієнт, і йому присвячено сторінку **[Клієнт](../client/index.md)**. + У тесті термінал і порт не потрібні: передайте `Client` сам об'єкт сервера — + `Client(mcp)`. Цьому теж присвячено цілу сторінку: **[Тестування](testing.md)**. ## Чого ви не писали {#what-you-did-not-write} @@ -128,7 +127,7 @@ asyncio.run(main()) * JSON Schema. `a: int, b: int` *і є* схема для `add`. * Обробник запитів. `tools/list`, `resources/read`, `prompts/get`: усе обслуговується за вас. * Оголошення можливостей. `MCPServer` зробив його за вас. -* Жодного рядка протоколу. Узгодження версії, обрамлення JSON-RPC, обмін можливостями: усе це відбулося всередині `mcp dev` і `Client(mcp)`, і ви цього не бачили. +* Жодного рядка протоколу. Узгодження версії, обрамлення JSON-RPC, обмін можливостями: усе це відбулося всередині `mcp dev` і `client.py`, і ви цього не бачили. У цьому співвідношенні й увесь сенс SDK. @@ -139,6 +138,6 @@ asyncio.run(main()) * Один декоратор на примітив: `@mcp.tool()`, `@mcp.resource(uri)`, `@mcp.prompt()`. Назва, опис і схема беруться з функції. * URI з `{param}` створює **шаблон** ресурсу, який показується окремо від конкретних ресурсів. * **Можливості** сервера оголошуються за вас, а клієнт просить лише те, що сервер оголосив. -* `Client(mcp)` під'єднується до об'єкта сервера в пам'яті: ваш тестовий стенд із першого дня. +* `Client("http://localhost:8000/mcp")` говорить із вашим запущеним сервером. Передайте йому натомість об'єкт сервера — `Client(mcp)` — і це ваш тестовий стенд із першого дня. Далі — **[Під'єднання до справжнього хоста](real-host.md)**: цей сервер усередині Claude Desktop або IDE, по-справжньому. Потім **[Тестування](testing.md)**: одна сторінка, один клієнт у пам'яті — і більше ніколи не доведеться гадати, чи воно працює. Після цього кожен примітив отримує власну сторінку, починаючи з того, яким керує модель: **[Інструменти](../servers/tools.md)**. diff --git a/i18n/uk/pages/get-started/testing.md b/i18n/uk/pages/get-started/testing.md index f384dcde..3e82e7bd 100644 --- a/i18n/uk/pages/get-started/testing.md +++ b/i18n/uk/pages/get-started/testing.md @@ -1,13 +1,13 @@ --- translation: - sections: ['4926721070127497', c52a1de2b6b32f40, 8e792bf8c7489ec6, 627195f7159e24ef] + sections: [5d13c2f0ba42c0d2, c52a1de2b6b32f40, 8e792bf8c7489ec6, 38552ea228b0a04f] tool: 1 --- # Тестування {#testing} -Python SDK містить клас `Client` із **транспортом у пам'яті**: передайте йому об'єкт сервера — і він під'єднається до нього напряму. +Клас `Client` із SDK — той самий, що під'єднується за URL чи запускає підпроцес, — уміє під'єднуватися й **у пам'яті**: передайте йому об'єкт сервера, і він говоритиме з ним напряму. -Жодного підпроцесу. Жодного порту. Узагалі жодного транспорту. Та сама ідея, що й `TestClient` у FastAPI. +Жодного підпроцесу. Жодного порту. Нічого не передається мережею. Та сама ідея, що й `TestClient` у FastAPI. ## Базове використання {#basic-usage} @@ -96,12 +96,12 @@ async def test_call_add_tool(client: Client): Залишайте його ввімкненим у тестах. У робочому коді він не має сенсу. -## У тому самому процесі за замовчуванням {#in-process-by-default} +## Нейтральність щодо покоління за замовчуванням {#era-neutral-by-default} !!! note `Client(mcp)` під'єднується в межах процесу й за замовчуванням **нейтральний щодо покоління**: він зондує сервер і - обирає відповідний шлях протоколу. Зафіксуйте `mode="legacy"`, якщо тест перевіряє семантику, специфічну для - старого покоління — push семплювання (sampling) чи еліцитації (elicitation), `message_handler`, — і приберіть там `raise_exceptions=True`: + обирає відповідний шлях протоколу. Зафіксуйте `mode="legacy"`, якщо тест перевіряє семантику, властиву саме + старому поколінню — push семплювання (sampling) чи еліцитації (elicitation), `message_handler`, — і приберіть там `raise_exceptions=True`: з'єднання старого покоління взагалі нічого не узагальнює, а прапорець повторно викидає збій усередині завдання сервера, а не у вашому тесті. diff --git a/i18n/uk/pages/handlers/multi-round-trip.md b/i18n/uk/pages/handlers/multi-round-trip.md index 68c6ba20..d8a0cd07 100644 --- a/i18n/uk/pages/handlers/multi-round-trip.md +++ b/i18n/uk/pages/handlers/multi-round-trip.md @@ -1,6 +1,6 @@ --- translation: - sections: [74011e683045eea9, 9b64cc175c18b6a9, 4b41be4824030397, e3b1502da786ec33, 71e41161f143c6a9, 9ec2c1eeb8c36378, 8dd027377d46448b, f81491125dcbfe8b] + sections: [74011e683045eea9, 9b64cc175c18b6a9, 4b41be4824030397, e3b1502da786ec33, 71e41161f143c6a9, 9ec2c1eeb8c36378, b47667184ca5b516, f81491125dcbfe8b] tool: 1 --- # Багатораундові запити {#multi-round-trip-requests} @@ -164,7 +164,7 @@ TTL, прив'язка до принципала й прив'язка до за ## Результат версії 2026-07-28 {#a-2026-07-28-result} -`InputRequiredResult` існує лише у версії протоколу **2026-07-28**. `Client(server)` у пам'яті узгоджує її за вас; мережею її виявляє `mode="auto"`. Після під'єднання `client.protocol_version` покаже, що саме ви отримали. +`InputRequiredResult` існує лише у версії протоколу **2026-07-28**. Режим `mode="auto"`, типовий для `Client`, виявляє її на будь-якому з'єднанні. Після під'єднання `client.protocol_version` покаже, що саме ви отримали. !!! warning У сесії, старшій за 2026, `InputRequiredResult` просто нікуди покласти. Поверніть його з обробника на diff --git a/i18n/uk/pages/handlers/progress.md b/i18n/uk/pages/handlers/progress.md index e6d9f4cb..7e4a22db 100644 --- a/i18n/uk/pages/handlers/progress.md +++ b/i18n/uk/pages/handlers/progress.md @@ -1,6 +1,6 @@ --- translation: - sections: [5315262fe26b33e1, 9d8e98840f1b78f0, 0284b215e85366c4, 8534d8dbb4053a70, 2966fac6fe697007] + sections: [5315262fe26b33e1, 9d8e98840f1b78f0, 52d6009a07e770ea, 8534d8dbb4053a70, 2966fac6fe697007] tool: 1 --- # Перебіг виконання {#progress} @@ -29,19 +29,17 @@ translation: Клієнт підписується **окремо для кожного виклику**, передаючи `progress_callback=` у `call_tool`: -```python title="client.py" hl_lines="7 16" +```python title="client.py" hl_lines="5 14" import anyio from mcp import Client -from server import mcp - async def show(progress: float, total: float | None, message: str | None) -> None: print(f"{message} ({progress}/{total})") async def main() -> None: - async with Client(mcp) as client: + async with Client("http://localhost:8000/mcp") as client: result = await client.call_tool( "import_catalog", {"urls": ["https://example.com/a.json", "https://example.com/b.json"]}, @@ -56,28 +54,31 @@ anyio.run(main) Колбек — це `async`-функція, яка приймає рівно те, що повідомив сервер: `progress`, `total`, `message`. !!! info - `Client(mcp)` під'єднується безпосередньо до об'єкта сервера, у пам'яті, — це той самий клієнт, на якому - побудована сторінка **[Тестування](../get-started/testing.md)**. Параметр `progress_callback` однаковий - незалежно від транспорту, який використовує `Client`; а от *хронометраж*, який ви зараз побачите, - властивий саме з'єднанню в пам'яті. Воно запускає колбек одразу на місці, тож кожен звіт надходить до - того, як `call_tool` поверне результат. На справжньому транспорті сповіщення змагаються з результатом, - і повільний колбек може ще виконуватися після того, як `call_tool` уже повернув результат. + Параметр `progress_callback` однаковий незалежно від того, що ви передали в `Client`: URL, як тут, + `StdioServerParameters` чи об'єкт сервера в тесті. Проте зважайте на хронометраж на справжньому + транспорті. Кожне сповіщення доставляється окремо, поруч із відповіддю, тож повільний колбек може + ще виконуватися після того, як `call_tool` уже повернув результат. Лише тестове з'єднання в межах + процесу запускає колбек одразу на місці й гарантує, що кожен звіт надійде раніше. ### Спробуйте самі {#try-it} -Покладіть `client.py` поруч із `server.py` і запустіть: +Запустіть `server.py` через HTTP, а потім запустіть клієнт у другому терміналі: + +```console +uv run mcp run server.py --transport streamable-http +``` ```console python client.py ``` ```text -Imported https://example.com/a.json (1/2) -Imported https://example.com/b.json (2/2) +Imported https://example.com/a.json (1.0/2.0) +Imported https://example.com/b.json (2.0/2.0) {'result': 'Imported 2 records.'} ``` -Кожен `await ctx.report_progress(...)` на сервері перетворився на один виклик `show` на клієнті, у тому самому порядку, і обидва рядки надрукувалися **до** того, як `call_tool` повернув результат. Перебіг не пакується в результат — він надходить потоком, поки інструмент іще працює. +Кожен `await ctx.report_progress(...)` на сервері перетворився на один виклик `show` на клієнті, у тому самому порядку. Перебіг не пакується в результат. Він надходить потоком, поки інструмент іще працює. !!! warning `progress_callback` належить **виклику**, а не `Client`. Аргументу конструктора для нього немає, diff --git a/i18n/uk/pages/protocol-versions.md b/i18n/uk/pages/protocol-versions.md index fcbb2347..bd5202a2 100644 --- a/i18n/uk/pages/protocol-versions.md +++ b/i18n/uk/pages/protocol-versions.md @@ -1,6 +1,6 @@ --- translation: - sections: [478fd619e5f90ef8, aef094a00e44e248, bab8cbf3449fa7e9, df1809b15a58335b, 5f9d8c2336ed0239, f54974398e43ddef, b24443dd78584870] + sections: [d98e337bf28845e0, dd642acbba36f615, f547b3e76da19c94, 149513378588da5c, 4e149ed992046709, f54974398e43ddef, b24443dd78584870] tool: 1 --- # Версії протоколу {#protocol-versions} @@ -11,9 +11,17 @@ translation: Перейматися цим майже ніколи не доводиться, бо `Client` домовляється за вас. Ця сторінка — про єдиний аргумент конструктора, який цим керує, `mode=`, і про три випадки, коли його змінюють. +Кожен фрагмент на цій сторінці — це `client.py`, що розмовляє з `server.py` книгарні Bookshop зі сторінки **[Клієнт](client/index.md)**. Запустіть цей сервер в одному терміналі: + +```console +uv run mcp run server.py --transport streamable-http +``` + +Потім запускайте кожен фрагмент у другому терміналі командою `python client.py`. + ## `mode="auto"` {#modeauto} -```python title="client.py" hl_lines="14-15" +```python title="client.py" hl_lines="7-8" --8<-- "docs_src/protocol_versions/tutorial001.py" ``` @@ -31,13 +39,14 @@ translation: Оце й уся можливість. Один `Client`, сервер будь-якого покоління, жодних розгалужень у вашому коді. !!! info - `MCPServer` відповідає на `server/discover` на кожному транспорті — in-memory, stdio, streamable - HTTP — тож із власним сервером `auto` завжди зупиняється на `2026-07-28`. Запасний шлях - спрацьовує лише проти справжнього сервера, випущеного до 2026, — саме тоді, коли він і потрібен. + `MCPServer` відповідає на `server/discover` на кожному транспорті — Streamable HTTP, stdio та + внутрішньопроцесному з'єднанні, яким користуються ваші тести, — тож із власним сервером `auto` + завжди зупиняється на `2026-07-28`. Запасний шлях спрацьовує лише проти справжнього сервера, + випущеного до 2026, — саме тоді, коли він і потрібен. ## `mode="legacy"` {#modelegacy} -```python title="client.py" hl_lines="14" +```python title="client.py" hl_lines="7" --8<-- "docs_src/protocol_versions/tutorial002.py" ``` @@ -61,7 +70,7 @@ translation: `mode` також приймає рядок сучасної версії протоколу. Сьогодні ця множина — рівно `["2026-07-28"]`. -```python title="client.py" hl_lines="14" +```python title="client.py" hl_lines="7" --8<-- "docs_src/protocol_versions/tutorial003.py" ``` @@ -94,7 +103,7 @@ ValueError: mode must be 'legacy', 'auto', or one of ['2026-07-28']; got '2025-0 Тож збережіть її. Після з'єднання в режимі `auto` `client.session.discover_result` містить точний `DiscoverResult`, який надіслав сервер: його `supported_versions`, `capabilities`, `instructions` і відомості про себе, які сервер записав у `_meta` результату. Наступного разу передайте його назад як `prior_discover=`: -```python title="client.py" hl_lines="15 17" +```python title="client.py" hl_lines="8 10" --8<-- "docs_src/protocol_versions/tutorial004.py" ``` diff --git a/i18n/uk/pages/run/asgi.md b/i18n/uk/pages/run/asgi.md index 8525330f..84e57d87 100644 --- a/i18n/uk/pages/run/asgi.md +++ b/i18n/uk/pages/run/asgi.md @@ -1,6 +1,6 @@ --- translation: - sections: [1062ef792791488a, 4be2b831547184a9, 374b049e770385f2, b72f6947089e6de0, b172c9db7831bb31, 70b9ece244ca1b0c, cba78e052898c3f6, f06bdb541cb0b469, fb82d526320b7cc3] + sections: [1062ef792791488a, 4be2b831547184a9, 374b049e770385f2, b72f6947089e6de0, b172c9db7831bb31, 10394c6f16601638, cba78e052898c3f6, f06bdb541cb0b469, 6dc898ccc5a903f9] tool: 1 --- # Додавання до наявного застосунку {#add-to-an-existing-app} @@ -99,7 +99,7 @@ uvicorn server:app --8<-- "docs_src/asgi/tutorial004.py" ``` -Тепер клієнти під'єднуються до `/notes`, а не до `/notes/mcp`. +Тепер клієнти під'єднуються до `/notes/`, а не до `/notes/mcp`. ## CORS для браузерних клієнтів {#cors-for-browser-clients} @@ -142,4 +142,4 @@ uvicorn server:app * Браузерним клієнтам потрібен CORS: `allow_headers` для заголовків запиту `Mcp-*`, `expose_headers=["Mcp-Session-Id"]` для відповіді. * `@mcp.custom_route()` додає звичайні HTTP-кінцеві точки без автентифікації поруч із `/mcp`. -Щойно сервер стане досяжним за справжньою URL-адресою, **[Клієнт](../client/index.md)** під'єднається до нього за цією URL-адресою, а не через об'єкт сервера. +Щойно сервер стане досяжним за справжньою URL-адресою, **[Клієнт](../client/index.md)** під'єднається до нього за цією URL-адресою. diff --git a/i18n/uk/pages/run/authorization.md b/i18n/uk/pages/run/authorization.md index d293bd32..250704df 100644 --- a/i18n/uk/pages/run/authorization.md +++ b/i18n/uk/pages/run/authorization.md @@ -1,60 +1,64 @@ --- translation: - sections: [d62c13457fc4a534, 80e73abaca6e0652, d1dc4c54cd00ec9c, 14ad3bc7904036bb, 5225f127bc1b9c77, fe1626fdd5aad1da, 4556cb7ea1a04a31] + sections: [d62c13457fc4a534, 80e73abaca6e0652, 128a492d18295f64, 14ad3bc7904036bb, 54a6697833fcc1d9, fe1626fdd5aad1da, 811d083c1da8bcf6] tool: 1 --- # Авторизація {#authorization} -Через Streamable HTTP ваш MCP-сервер — це звичайний вебсервіс, і захищати його слід так само, як будь-який інший вебсервіс: bearer-токенами OAuth 2.1. +Через Streamable HTTP ваш MCP-сервер — це звичайний вебсервіс, і захищати його треба так само, як будь-який вебсервіс: bearer-токенами OAuth 2.1. -У термінах OAuth ваш сервер — це **сервер ресурсів** (resource server). Він ніколи нікого не автентифікує й ніколи не видає токенів. Він робить одне: дивиться на заголовок `Authorization` у кожному запиті й вирішує, чи придатний токен у ньому. +У термінах OAuth ваш сервер — це **сервер ресурсів**. Він нікого не автентифікує і ніколи не видає токенів. Він робить одне: дивиться на заголовок `Authorization` кожного запиту й вирішує, чи дійсний токен у ньому. -Ця сторінка — про серверний бік. Клієнт, який знаходить ваш сервер авторизації й отримує токен, описано на сторінці **[Клієнти OAuth](../client/oauth-clients.md)**. +Ця сторінка — про серверну частину. Клієнт, який знаходить ваш сервер авторизації й отримує токен, описаний на сторінці **[OAuth-клієнти](../client/oauth-clients.md)**. ## Три сторони {#the-three-parties} -* **Сервер авторизації** автентифікує людей і видає токени доступу. Ви його не пишете. Це ваш постачальник ідентичності (Auth0, Keycloak, Entra, ваш власний). +* **Сервер авторизації** автентифікує людей і видає токени доступу. Його ви не пишете. Це ваш постачальник ідентичності (Auth0, Keycloak, Entra, власний). * **Сервер ресурсів** — це ваш MCP-сервер. Він перевіряє токен у кожному запиті. * **Клієнт** з'ясовує, якому серверу авторизації ви довіряєте, отримує від нього токен і надсилає його вам як `Authorization: Bearer `. -Оце й увесь трикутник. Усе на цій сторінці стосується середнього пункту. +Оце й увесь трикутник. Усе на цій сторінці — про середній пункт. ## Верифікатор токенів {#a-token-verifier} -SDK не має власної думки про те, який вигляд має дійсний токен. Це визначаєте ви, реалізувавши **`TokenVerifier`**: +SDK не має власної думки про те, який токен вважати дійсним. Це визначаєте ви, реалізуючи **`TokenVerifier`**: -```python title="server.py" hl_lines="12-14 19-24" +```python title="server.py" hl_lines="14-16 21-27" --8<-- "docs_src/authorization/tutorial001.py" ``` -* `TokenVerifier` — це протокол з одним асинхронним методом. `verify_token` отримує сирий токен із заголовка `Authorization` і повертає **`AccessToken`**, якщо він дійсний, або `None`, якщо ні. Більше нічого реалізовувати не треба. -* Цей верифікатор шукає токен у таблиці. Справжній перевіряє підпис JWT або звертається до кінцевої точки інтроспекції токенів сервера авторизації. Цей код — ваш; SDK лише викликає його. -* `token_verifier=` і `auth=` завжди йдуть разом. Передайте одне без іншого — і `MCPServer(...)` викине `ValueError` ще до того, як обслужить хоч один запит. +* `TokenVerifier` — це протокол з одним асинхронним методом. `verify_token` отримує сирий токен із заголовка `Authorization` і повертає **`AccessToken`**, якщо токен дійсний, або `None`, якщо ні. Більше нічого реалізовувати не потрібно. +* Цей шукає токен у таблиці; кожен запис фіксує ресурс, для якого токен видано. Справжній перевіряє підпис JWT або викликає кінцеву точку інтроспекції токенів сервера авторизації і повідомляє, для кого видано токен (його `aud`), у `AccessToken.resource`. Цей код — ваш; SDK його лише викликає. +* `token_verifier=` і `auth=` завжди йдуть у парі. Передайте одне без іншого — і `MCPServer(...)` викине `ValueError`, ще не обслуживши жодного запиту. -`AuthSettings` — це публічне обличчя вашого сервера ресурсів: +`AuthSettings` — публічне обличчя вашого сервера ресурсів: * `issuer_url`: сервер авторизації, що видає ваші токени. -* `resource_server_url`: публічний URL цієї MCP-кінцевої точки. Він указує, для *якого* ресурсу призначено токен, і саме тут розміщено документ виявлення. +* `resource_server_url`: публічна URL-адреса цієї кінцевої точки MCP. Вона вказує, для *якого* ресурсу призначено токен, і саме за нею лежить документ виявлення. * `required_scopes`: кожен токен мусить містити їх усі. +* `validate_token_resource`: відхиляти будь-який токен, чий `AccessToken.resource` не дорівнює `resource_server_url`. Якщо залишити його незаданим, коли `resource_server_url` задано, з'являється попередження (`MCPDeprecationWarning`), а поведінка відповідає `False`; у 3.0 типовим значенням для серверів ресурсів стане `True`. + * Увімкніть його, якщо ваш сервер авторизації прив'язує токени до `resource`, який запросив клієнт, — а MCP-клієнти завжди його надсилають. `resource_server_url` має бути точно тією URL-адресою, до якої під'єднуються клієнти. + * Залиште вимкненим, якщо ваш сервер авторизації використовує власні ідентифікатори аудиторії (ідентифікатор API в Auth0, ідентифікатор застосунку в Entra), і натомість перевіряйте `aud` у своєму верифікаторі, повертаючи `None` для токена, що не призначений цьому серверу. + * Якщо `aud` — список, покладіть у `resource` той елемент, що дорівнює `resource_server_url`. !!! tip - `examples/servers/simple-auth/` у репозиторії SDK містить `IntrospectionTokenVerifier`, який звертається - до кінцевої точки [RFC 7662](https://datatracker.ietf.org/doc/html/rfc7662) справжнього сервера авторизації. Саме таку форму мають більшість продакшн-верифікаторів. + У `examples/servers/simple-auth/` у репозиторії SDK є `IntrospectionTokenVerifier`, який викликає + кінцеву точку [RFC 7662](https://datatracker.ietf.org/doc/html/rfc7662) справжнього сервера авторизації. Саме таку форму має більшість верифікаторів у реальних розгортаннях. -## Що з'являється через HTTP {#what-you-get-over-http} +## Що ви отримуєте через HTTP {#what-you-get-over-http} -Авторизація живе в HTTP-заголовках, тож існує лише на HTTP-транспортах. Запустіть сервер на тому, який розгортаєте: `mcp.run(transport="streamable-http")` розміщує його на `http://127.0.0.1:8000/mcp`, а решта — на сторінці **[Запуск сервера](index.md)**. Тепер застосунок має два маршрути: +Авторизація живе в HTTP-заголовках, тож існує лише на HTTP-транспортах. Запускайте її на тому, який розгортаєте: `mcp.run(transport="streamable-http")` піднімає сервер на `http://127.0.0.1:8000/mcp`, а решту описано на сторінці **[Запуск сервера](index.md)**. Тепер застосунок має два маршрути: ```text /mcp /.well-known/oauth-protected-resource/mcp ``` -Ви зареєстрували один інструмент. Другий маршрут належить SDK. +Ви зареєстрували один інструмент. Другий маршрут — від SDK. ### Виявлення {#discovery} -Зробіть `GET` на цей well-known-шлях — і отримаєте **[RFC 9728](https://datatracker.ietf.org/doc/html/rfc9728) Protected Resource Metadata**, побудовані безпосередньо з вашого `AuthSettings`: +Зробіть `GET` на цей well-known шлях — і отримаєте **[RFC 9728](https://datatracker.ietf.org/doc/html/rfc9728) Protected Resource Metadata**, побудовані прямо з ваших `AuthSettings`: ```json { @@ -65,7 +69,7 @@ SDK не має власної думки про те, який вигляд м } ``` -Саме завдяки цьому документу клієнт, який ніколи не чув про ваш сервер, знаходить дорогу: він читає `authorization_servers` і йде туди по токен. Ви не написали з нього жодного рядка. +Саме завдяки цьому документу клієнт, який ніколи не чув про ваш сервер, знаходить шлях усередину: він читає `authorization_servers` і йде туди по токен. Ви не написали з нього жодного рядка. !!! check Зверніться до `/mcp` без токена (або з таким, для якого ваш верифікатор повернув `None`) — і запит @@ -78,26 +82,26 @@ SDK не має власної думки про те, який вигляд м {"error": "invalid_token", "error_description": "Authentication required"} ``` - Нічого не було розібрано, і жоден інструмент не виконався. А вказівник `resource_metadata` у `WWW-Authenticate` — - саме те, що робить виявлення автоматичним: 401 -> документ метаданих -> сервер авторизації -> токен -> повторна спроба. + Нічого не розібрано, жоден інструмент не виконувався. А вказівник `resource_metadata` у `WWW-Authenticate` — + це те, що робить виявлення автоматичним: 401 -> документ метаданих -> сервер авторизації -> токен -> повторна спроба. !!! warning - Ніщо з цього не захищає `stdio`. Канал не має заголовка `Authorization`, тож `token_verifier` там ніколи - не викликається. Межа безпеки `stdio`-сервера — це процес, який його запустив. Те саме - стосується `Client(mcp)` у пам'яті, який ви використовуєте в тестах: він під'єднується безпосередньо до об'єкта сервера - й оминає HTTP-рівень, разом з авторизацією. + Нічого з цього не захищає `stdio`. Канал (pipe) не має заголовка `Authorization`, тож `token_verifier` там ніколи + не викликається. Межа безпеки `stdio`-сервера — процес, який його запустив. Те саме + стосується `Client(mcp)` у пам'яті, який використовують у тестах: він під'єднується безпосередньо до об'єкта сервера + й оминає HTTP-рівень разом з авторизацією. ## Ідентичність того, хто викликає {#the-callers-identity} Усередині будь-якого обробника **`get_access_token()`** — це `AccessToken`, який ваш верифікатор повернув для поточного запиту: -```python title="server.py" hl_lines="4 32-35" +```python title="server.py" hl_lines="4 35-38" --8<-- "docs_src/authorization/tutorial002.py" ``` -* Це працює в інструментах, ресурсах і промптах, і нічого нікуди передавати не треба: middleware авторизації зберігає його в контекстній змінній для кожного запиту. -* Повертається **той самий об'єкт, який побудував ваш верифікатор**: `client_id`, `scopes`, `subject`, `expires_at` і будь-які додаткові `claims`, які ви додали. Це й є зачіпка для правил на рівні окремих інструментів: прочитайте scopes і відмовте. -* Поза автентифікованим HTTP-запитом він повертає `None`. У пам'яті й через `stdio` це завжди `None`. +* Це працює в інструментах, ресурсах і промптах, і нічого не треба передавати вручну: middleware авторизації зберігає його в контекстній змінній для кожного запиту. +* Повертається **той самий об'єкт, що його побудував ваш верифікатор**: `client_id`, `scopes`, `subject`, `expires_at` і будь-які додаткові `claims`, які ви додали. Це і є зачіпка для правил на рівні окремих інструментів: прочитайте scopes і відмовте. +* Поза автентифікованим HTTP-запитом функція повертає `None`. У пам'яті та через `stdio` це завжди `None`. Викличте `whoami` з `Authorization: Bearer alice-token` — і модель прочитає: @@ -109,22 +113,22 @@ alice (scopes: notes:read) SDK дає вам половину сервера ресурсів: перевірити, оголосити, відмовити. Він не дає сторінки входу, екрана згоди чи токена. -Щоб побачити всі три сторони в русі, запустіть `examples/servers/simple-auth/` з репозиторію SDK (невеликий сервер авторизації та сервер ресурсів, налаштований точно як на цій сторінці), а потім спрямуйте на нього `examples/clients/simple-auth-client/`, щоб пройти повний шлях виявлення й отримання токена. +Щоб побачити всі три сторони в русі, запустіть `examples/servers/simple-auth/` з репозиторію SDK (невеликий сервер авторизації та сервер ресурсів, налаштований точно як на цій сторінці), а потім спрямуйте на нього `examples/clients/simple-auth-client/` — і пройдете весь шлях від виявлення до токена. !!! info - Є другий аргумент конструктора, `auth_server_provider=`, який вбудовує повноцінний сервер - авторизації всередину вашого MCP-сервера. Він з'явився ще до розділення AS/RS, навколо якого - побудовано специфікацію авторизації MCP. Новим серверам не слід до нього вдаватися. + Є ще другий аргумент конструктора, `auth_server_provider=`, який вбудовує повноцінний сервер авторизації + всередину вашого MCP-сервера. Він з'явився раніше за розділення AS/RS, навколо якого побудована специфікація + авторизації MCP. У нових серверах його використовувати не слід. -Сервер авторизації також може прийняти підписане твердження корпоративного постачальника ідентичності замість того, щоб користувач проходив екран згоди, і SDK підтримує обидва боки цього обміну. Про цей grant і клієнта, що його пред'являє, — на сторінці **[Твердження ідентичності](../client/identity-assertion.md)**. +Сервер авторизації може також прийняти підписане твердження від корпоративного постачальника ідентичності замість того, щоб користувач проклацував екран згоди, і SDK підтримує обидві сторони цього обміну. Цей грант і клієнт, що його пред'являє, описані на сторінці **[Твердження ідентичності](../client/identity-assertion.md)**. ## Підсумки {#recap} -* Через Streamable HTTP ваш сервер — це **сервер ресурсів** OAuth 2.1: він перевіряє токени й ніколи їх не видає. -* `TokenVerifier` — це вся поверхня інтеграції: один асинхронний метод, на вході токен, на виході `AccessToken | None`. -* `token_verifier=` і `auth=AuthSettings(issuer_url=..., resource_server_url=..., required_scopes=[...])` завжди йдуть разом. +* Через Streamable HTTP ваш сервер — **сервер ресурсів** OAuth 2.1: він перевіряє токени й ніколи їх не видає. +* `TokenVerifier` — уся поверхня інтеграції: один асинхронний метод, токен на вході, `AccessToken | None` на виході. +* `token_verifier=` і `auth=AuthSettings(issuer_url=..., resource_server_url=..., required_scopes=[...])` завжди йдуть у парі. * SDK публікує [RFC 9728](https://datatracker.ietf.org/doc/html/rfc9728) Protected Resource Metadata за адресою `/.well-known/oauth-protected-resource/...` і відповідає на неавтентифіковані запити кодом 401, чий заголовок `WWW-Authenticate` вказує на них. Оце й уся історія виявлення. * `get_access_token()` у будь-якому обробнику — це той, хто викликає. -* Авторизація — справа HTTP. `stdio` та клієнт у пам'яті ніколи її не бачать. +* Авторизація — справа HTTP. `stdio` і тестовий клієнт у пам'яті ніколи її не бачать. -Клієнтська половина (виявлення вашого сервера авторизації й отримання токена за вас) — на сторінці **[Клієнти OAuth](../client/oauth-clients.md)**. А клієнт, який *стверджує* ідентичність замість того, щоб запитувати її в користувача, — на сторінці **[Твердження ідентичності](../client/identity-assertion.md)**. +Клієнтська половина (виявлення сервера авторизації й отримання токена за вас) — на сторінці **[OAuth-клієнти](../client/oauth-clients.md)**. А клієнт, який *стверджує* ідентичність замість того, щоб просити її в користувача, — на сторінці **[Твердження ідентичності](../client/identity-assertion.md)**. diff --git a/i18n/uk/pages/run/deploy.md b/i18n/uk/pages/run/deploy.md index fe83a33e..a09c4928 100644 --- a/i18n/uk/pages/run/deploy.md +++ b/i18n/uk/pages/run/deploy.md @@ -1,6 +1,6 @@ --- translation: - sections: [28221886b198784f, f88ea1f1614f3a1d, ce926d686730b6d0, 3be24f8ad8bb5ab9, 3fad24032b2224ff, f25a7f860e579ecb, e758745df6fb7b0a] + sections: [28221886b198784f, f88ea1f1614f3a1d, 2e76f5cb9df15042, ce926d686730b6d0, 3be24f8ad8bb5ab9, 3fad24032b2224ff, f25a7f860e579ecb, 697b01d95080880d] tool: 1 --- # Розгортання та масштабування {#deploy-scale} @@ -47,6 +47,22 @@ translation: розгорнутий сервер, який відхиляє кожне з'єднання, — це список дозволених Host, доки не доведено протилежне. **[Усунення несправностей](../troubleshooting.md)** теж починається звідси. +## За проксі, що завершує TLS {#behind-a-tls-terminating-proxy} + +Якщо TLS завершується на проксі (ingress, балансувальник навантаження, Caddy, nginx), а uvicorn за ним обслуговує звичайний HTTP, скажіть uvicorn довіряти заголовкам `X-Forwarded-*` від проксі: + +```console +uvicorn server:app --proxy-headers --forwarded-allow-ips='' +``` + +Без цього застосунок вважає, що його обслуговують через `http://`, і будь-яке перенаправлення, яке він видає (зазвичай це `/mcp` → `/mcp/`), веде на `http://…`. Python-клієнт відмовляється переходити з HTTPS-ендпоінта на звичайний HTTP і прямо про це каже: + +```text +MCPError: Redirect to http://mcp.example.com/mcp/ not followed: it would downgrade this HTTPS endpoint to plain HTTP. +``` + +Тимчасовий обхід на боці клієнта — вказати точний URL, який обслуговує сервер (`https://mcp.example.com/mcp/`, зі скісною рискою наприкінці), щоб перенаправлення не було взагалі. Справжнє виправлення — прапорець вище. `FORWARDED_ALLOW_IPS` — той самий параметр у вигляді змінної середовища; `*` довіряє кожному проміжному вузлу, що правильно лише тоді, коли до uvicorn не може дістатися ніхто, крім проксі. + ## Робочі процеси, і для кого потрібні липкі сесії {#workers-and-who-has-to-be-sticky} Щойно ім'я хоста відповідає, поставте за ним більше ніж один робочий процес. Для цього в SDK немає жодного перемикача; Starlette-застосунок масштабують так само, як і будь-який ASGI-застосунок, — передають об'єкт чомусь, що вміє створювати дочірні процеси: @@ -170,6 +186,7 @@ python -c "import secrets; print(secrets.token_hex(32))" ## Підсумки {#recap} * За замовчуванням застосунок відповідає лише на запити, адресовані localhost. `transport_security=TransportSecuritySettings(allowed_hosts=[...], allowed_origins=[...])` — це ворота виходу в світ: доки його не передати, кожен запит за справжнім іменем хоста — це `421`, а причина є лише в лозі сервера. +* За проксі, що завершує TLS, запускайте uvicorn з `--proxy-headers --forwarded-allow-ips=...`, інакше його перенаправлення ведуть на `http://`, і клієнт їх відхиляє. * На 2026-07-28 немає сесії і нічого, до чого балансувальник навантаження мав би прив'язуватися. `stateless_http=True` — перемикач лише для старого покоління, бо сучасний запит маршрутизується й отримує відповідь ще до того, як цей прапорець узагалі прочитають. * Типовий ключ `requestState` — це `os.urandom(32)`, випущений окремо в кожному процесі. Багатораундова повторна спроба, що потрапила до іншого робочого процесу, збоїть з `-32602` *«Invalid or expired requestState»*. * Виправлення — `RequestStateSecurity(keys=[...])` **і** те саме ім'я сервера на кожному екземплярі. Ім'я — типове твердження про аудиторію токена. Ті самі ключі, те саме ім'я. diff --git a/i18n/uk/pages/run/index.md b/i18n/uk/pages/run/index.md index d5f388f3..5d4f90c8 100644 --- a/i18n/uk/pages/run/index.md +++ b/i18n/uk/pages/run/index.md @@ -1,6 +1,6 @@ --- translation: - sections: [fea8d769ff9edeba, ce8e2ad42f29ef71, 0d705efb19cf99c2, 2fd7cf825e6d2b2c, 9adc400e8c88e854, 318893ad8e2e9924, 6b63ab96b34476c0] + sections: [fea8d769ff9edeba, ce8e2ad42f29ef71, 0d705efb19cf99c2, 9fd2357154a5b7e7, 9adc400e8c88e854, 318893ad8e2e9924, 6b63ab96b34476c0] tool: 1 --- # Запуск сервера {#running-your-server} @@ -75,6 +75,11 @@ Inspector робить рівно те саме, що й справжній хо * `max_request_body_size`: найбільший прийнятний розмір тіла запиту в байтах. За замовчуванням 4 МіБ; більші запити отримують HTTP 413 ще до розбору чи створення сесії. Збільшуйте його лише тоді, коли легітимні MCP-повідомлення перевищують цей розмір. +* `session_idle_timeout`: скільки секунд сесія старого покоління може простоювати без жодного запиту в роботі, + перш ніж сервер її закриє. За замовчуванням 1800. `None` вимикає це. Див. + [Час життя сесії та обмеження](legacy-clients.md#session-lifetime-and-limits). +* `max_sessions`: скільки сесій старого покоління один процес утримує одночасно. За замовчуванням 10 000. `None` + знімає обмеження. Описано в тому самому розділі. * `event_store`, `retry_interval`, `transport_security`: відновлюваність і захист від DNS-rebinding. Вони можуть зачекати, доки ви не розгорнете сервер деінде, крім localhost; `transport_security` описано на сторінці **[Розгортання та масштабування](deploy.md)**. !!! warning diff --git a/i18n/uk/pages/run/legacy-clients.md b/i18n/uk/pages/run/legacy-clients.md index 099aa9d3..3f8c9661 100644 --- a/i18n/uk/pages/run/legacy-clients.md +++ b/i18n/uk/pages/run/legacy-clients.md @@ -1,6 +1,6 @@ --- translation: - sections: [3d1663c18edc824c, d4fd37009a13f03d, af9f398a5a8b679a, 470c2dd144294d69, 8e45827e6d24e8c8, 91dfd0ce98ebb03c] + sections: [3d1663c18edc824c, 90956965ae6a1ca1, af9f398a5a8b679a, 5ce83b1f9d88da62, 0d9b5d13fffc94e5, 8e45827e6d24e8c8, 91dfd0ce98ebb03c] tool: 1 --- # Обслуговування клієнтів старого покоління {#serving-legacy-clients} @@ -21,15 +21,25 @@ SDK маршрутизує кожен запит за його заголовк ## Один обробник, обидва покоління {#one-handler-both-eras} -Ось інструмент, якому треба дещо запитати в користувача, і клієнти обох поколінь, що його викликають: +Ось інструмент, якому треба дещо запитати в користувача: -```python title="server.py" hl_lines="24 37-38" +```python title="server.py" hl_lines="21" --8<-- "docs_src/legacy_clients/tutorial001.py" ``` `reserve` потребує однієї речі, якої модель не надала: скільки примірників. Через `Annotated[..., Resolve(ask_quantity)]` інструмент це й оголошує (докладніше — на сторінці **[Залежності](../handlers/dependencies.md)**). Ніщо в `reserve` не називає версію, не перевіряє можливість і не розгалужується. -Обидва клієнти відкриті **одночасно**, на тому самому об'єкті `mcp`. `mode="legacy"` виконує рукостискання `initialize` — саме те з'єднання, яке відкриває клієнт до 2026 року. Другий клієнт бере значення за замовчуванням і потрапляє на `2026-07-28`. +Запустіть його через HTTP — і ось клієнти обох поколінь, що його викликають: + +```console +uv run mcp run server.py --transport streamable-http +``` + +```python title="client.py" hl_lines="14-15" +--8<-- "docs_src/legacy_clients/tutorial001_client.py" +``` + +Обидва клієнти відкриті **одночасно**, до того самого сервера, що працює. `mode="legacy"` виконує рукостискання `initialize` — саме те з'єднання, яке відкриває клієнт до 2026 року. Другий клієнт бере значення за замовчуванням і потрапляє на `2026-07-28`. Запустіть `python client.py` з другого термінала: ```text 2025-11-25 {'result': "Reserved 2 of 'Dune'."} @@ -62,6 +72,41 @@ SDK маршрутизує кожен запит за його заголовк надсилання пропущених SSE-подій клієнту, що перепід'єднується до *тієї самої* сесії), а не сховище сесій. Він ніколи не робить сесію досяжною з іншого процесу. +## Час життя сесій і обмеження {#session-lifetime-and-limits} + +Сесія старого покоління не живе вічно, і один процес не тримає їх необмежену кількість. +Цим керують два налаштування. Обидва — іменовані аргументи `run()`, `streamable_http_app()` +і `Server.streamable_http_app()`. Сучасні з'єднання (`2026-07-28`) і `stateless_http=True` +сесій не мають, тож жодне з налаштувань їх не стосується. + +| Налаштування | За замовчуванням | Що робить | Що бачить клієнт | Як вимкнути | +|---|---|---|---|---| +| `session_idle_timeout` | `1800` (30 хв) | Закриває сесію, у якій стільки часу нічого не було в роботі. | `404 Session not found`. Доведеться знову виконати `initialize`. | `None` | +| `max_sessions` | `10_000` | Відмовляється відкривати сесію понад цю кількість. Наявні сесії не зачіпаються, і нічого не витісняється. | `503 Too many open sessions` з кодом JSON-RPC `-32603`. | `None` | + +Що вважається «в роботі»: + +* Відкритий потік `GET`. Клієнти SDK тримають один відкритим, тож сесія під'єднаного + клієнта ніколи не спливає. +* Запит, на який досі відповідають. Виклик інструмента, що триває довше за тайм-аут, не + переривається, а відлік починається лише після його завершення. +* Більше нічого. Між запитами годинник іде. Будь-який запит у сесії перезапускає його, + зокрема й `ping`. Щойно сесія спливла, ніщо її не оживить. + +Клієнт, що завершує свою сесію через `DELETE`, звільняє її негайно. Так само й клієнт, чий +початковий запит відхилено. + +```python +mcp.run(transport="streamable-http", session_idle_timeout=None, max_sessions=50_000) +``` + +Обидві події потрапляють у лог сервера. Спливання — це `Session idle timeout` на рівні +`INFO`. Відмова у відкритті — `Refusing to open a new session: sessions are already open` +на рівні `WARNING`. + +Обмеження діють на процес. Із чотирма робочими процесами стеля — учетверо `max_sessions`, +і кожен робочий процес сам завершує свої сесії за тайм-аутом. + ## Єдиний перемикач: `stateless_http` {#the-one-knob-stateless_http} Якщо липкість — ціна, яку ви платити не готові, змінити можна рівно одну річ. @@ -87,8 +132,8 @@ SDK маршрутизує кожен запит за його заголовк !!! check Зробіть неправильно. `reserve` — той самий інструмент, що щойно обслужив обох клієнтів. - Розгорніть його зі `stateless_http=True`, під'єднайте тих самих двох клієнтів через HTTP - і викличте його з кожного. + Розгорніть його зі `stateless_http=True`, під'єднайте тих самих двох клієнтів і викличте + його з кожного. Сучасний клієнт, як і раніше, отримує `Reserved 2 of 'Dune'.` Сучасна гілка не змінилася. diff --git a/i18n/uk/pages/troubleshooting.md b/i18n/uk/pages/troubleshooting.md index 2f5df7a2..1b631ed4 100644 --- a/i18n/uk/pages/troubleshooting.md +++ b/i18n/uk/pages/troubleshooting.md @@ -1,6 +1,6 @@ --- translation: - sections: [2efaecdef109a5c5, fcacd3e66b8635a4, 25323d737dcf0261, 8a6e351ec756904d, 137454d469c867f5, 6392596bd6df54f0, 41126fa9c4fe432f, 480b6d7897e30ab4, d83bb682e708dde0, ebbed3449c499db4, 323ef84f6b4bebde, 30fd31be74169d9a, 656943c6cb567218, c2dc3b1007d2e987, 7cf5386b997d04e9, 0b59feed8384456e, 0cba47bae78d04eb, e4355f4c7cf4fb2e] + sections: [3d58228e81b99543, 170514ce901c4139, 17d61fad0a50d62b, 8a6e351ec756904d, 137454d469c867f5, 6392596bd6df54f0, 41126fa9c4fe432f, 480b6d7897e30ab4, d83bb682e708dde0, ebbed3449c499db4, 525cdf1755e29d4c, 30fd31be74169d9a, d2e88333d4f7841f, c2dc3b1007d2e987, d6eabf60cc366341, f798e815252852c2, 0cba47bae78d04eb, 2c218ba829abf74e] tool: 1 --- # Усунення несправностей {#troubleshooting} @@ -13,6 +13,12 @@ translation: --8<-- "docs_src/troubleshooting/tutorial001.py" ``` +Ці пункти звертаються до нього за адресою `http://localhost:8000/mcp`, тож залиште його працювати через HTTP: + +```console +uv run mcp run server.py --transport streamable-http +``` + Помилки, які цитує ця сторінка, справжні: власний набір тестів SDK відтворює кожну з них. ## `ExceptionGroup: unhandled errors in a TaskGroup (1 sub-exception)` {#exceptiongroup-unhandled-errors-in-a-taskgroup-1-sub-exception} @@ -23,7 +29,7 @@ translation: ```python async def main() -> None: - async with Client(mcp) as client: + async with Client("http://localhost:8000/mcp") as client: await client.read_resource("weather://Atlantis") ``` @@ -49,7 +55,7 @@ async def main() -> None: ```python async def main() -> None: - async with Client(mcp) as client: + async with Client("http://localhost:8000/mcp") as client: try: await client.read_resource("weather://Atlantis") except MCPError as e: @@ -67,7 +73,7 @@ async def main() -> None: ```python async def main() -> None: - client = Client(mcp) + client = Client("http://localhost:8000/mcp") tools = await client.list_tools() # RuntimeError ``` @@ -75,7 +81,7 @@ async def main() -> None: ```python async def main() -> None: - async with Client(mcp) as client: + async with Client("http://localhost:8000/mcp") as client: tools = await client.list_tools() ``` @@ -252,7 +258,7 @@ app = Starlette(routes=[Mount("/", app=mcp.streamable_http_app())], lifespan=lif ## `MCPError: Session not found` {#mcperror-session-not-found} -Сервер не впізнає `Mcp-Session-Id`, який надіслав ваш клієнт, майже завжди тому, що сервер **перезапустився** (або вас спрямували на інший екземпляр). Сесії живуть у пам'яті того одного процесу. +Сервер не впізнає `Mcp-Session-Id`, який надіслав ваш клієнт. Або сервер **перезапустився** (чи вас спрямували на інший екземпляр), або **сплив строк** сесії, бо протягом `session_idle_timeout` (за замовчуванням це 30 хвилин) у ній не було жодного запиту в роботі. Див. [Час життя сесії та обмеження](run/legacy-clients.md#session-lifetime-and-limits). Сесії живуть у пам'яті того одного процесу. Помилки в сервері тут немає. HTTP-відповідь — це `404`, тіло якого *є* JSON-RPC, тож, на відміну від `421` вище, python `Client` показує це повідомлення дослівно: @@ -262,9 +268,9 @@ app = Starlette(routes=[Mount("/", app=mcp.streamable_http_app())], lifespan=lif Виправлення — перепід'єднатися: вийдіть із блоку `async with Client(...)` і ввійдіть у новий, який узгодить свіжу сесію. Для довгоживучого клієнта це означає перехоплювати `MCPError` навколо викликів і перепід'єднуватися на це повідомлення, а не повторювати спроби всередині мертвої сесії. -Якщо це трапляється *без* перезапуску, у вас працює більше одного робочого процесу без липких сесій: кожен робочий процес тримає власну таблицю сесій, тож запит, спрямований не на той, опиняється тут. Про це та про два виправлення (липка маршрутизація або `stateless_http=True`) — сторінки **[Розгортання й масштабування](run/deploy.md)** і **[Обслуговування клієнтів старого покоління](run/legacy-clients.md)**. +Якщо це трапляється *без* перезапуску і клієнт не мовчав так довго, у вас працює більше одного робочого процесу без липких сесій: кожен робочий процес тримає власну таблицю сесій, тож запит, спрямований не на той, опиняється тут. Про це та про два виправлення (липка маршрутизація або `stateless_http=True`) — сторінки **[Розгортання й масштабування](run/deploy.md)** і **[Обслуговування клієнтів старого покоління](run/legacy-clients.md)**. -Для оператора сервера відповідний рядок у лозі — `Rejected request with unknown or expired session ID: `. Він пишеться на рівні `INFO`, тож за звичного порога `WARNING` його не видно. Бачити його сплесками одразу після розгортання — нормально: кожен під'єднаний клієнт перепід'єднується. +Для оператора сервера відповідний рядок у лозі — `Rejected request with unknown or expired session ID: `. Він пишеться на рівні `INFO`, тож за звичного порога `WARNING` його не видно. Бачити його сплесками одразу після розгортання — нормально: кожен під'єднаний клієнт перепід'єднується. Якщо ж сплив строк сесії, перед цим рядком іде `Session idle timeout`, теж на рівні `INFO`. ## `MCPError: Method not found` {#mcperror-method-not-found} @@ -276,7 +282,13 @@ app = Starlette(routes=[Mount("/", app=mcp.streamable_http_app())], lifespan=lif Ваш сервер хоче про щось запитати користувача, а цей клієнт ніколи не казав, що його можна питати. -Резолвер еліцитації (elicitation) відмовляє одразу, якщо під'єднаний клієнт не оголосив еліцитацію через форму, а `e.error.data` називає, чого саме бракує: +Це Bistro питає, перш ніж забронювати, — через резолвер: + +```python title="server.py" hl_lines="15-17 21" +--8<-- "docs_src/troubleshooting/tutorial007.py" +``` + +Запустіть його замість сервера Weather і викличте `book_table` із клієнта, якому не передали `elicitation_callback`. Резолвер відмовляє одразу, бо під'єднаний клієнт так і не оголосив еліцитацію (elicitation) через форму, а `e.error.data` називає, чого саме бракує: ```json { @@ -290,7 +302,7 @@ app = Starlette(routes=[Mount("/", app=mcp.streamable_http_app())], lifespan=lif ```python async def main() -> None: - async with Client(mcp, elicitation_callback=handle_elicitation) as client: + async with Client("http://localhost:8000/mcp", elicitation_callback=handle_elicitation) as client: result = await client.call_tool("book_table", {"date": "Friday"}) ``` @@ -315,14 +327,14 @@ async def main() -> None: Ваш обробник спробував звернутися до клієнта посеред запиту на з'єднанні, де виклик не має каналу, здатного нести запит від сервера. Є три конфігурації сервера, за яких виклик опиняється в такому становищі. -**З'єднання `2026-07-28`: будь-який транспорт, завжди.** Сучасний протокол узагалі не має запитів, ініційованих сервером, тож сервер відмовляє ще до того, як щось надіслано. `ctx.elicit()` усередині інструмента — класичний спосіб на це натрапити (у найпершому ж тесті в пам'яті, бо `Client(server)` узгоджує `2026-07-28`, навіть якщо його про це не просили), і передавання `elicitation_callback=` нічого не змінює, бо до клієнта ніколи не доходить запит, на який він міг би відповісти: +**З'єднання `2026-07-28`: будь-який транспорт, завжди.** Сучасний протокол узагалі не має запитів, ініційованих сервером, тож сервер відмовляє ще до того, як щось надіслано. `ctx.elicit()` усередині інструмента — класичний спосіб на це натрапити, зазвичай у найпершому ж **[тесті](get-started/testing.md)** цього інструмента в пам'яті, бо `Client(mcp)` узгоджує `2026-07-28`, навіть якщо його про це не просили. Передавання `elicitation_callback=` нічого не змінює, бо до клієнта ніколи не доходить запит, на який він міг би відповісти: ```python title="server.py" hl_lines="16" --8<-- "docs_src/troubleshooting/tutorial006.py" ``` ```python -async def main() -> None: +async def test_book_table() -> None: async with Client(mcp) as client: await client.call_tool("book_table", {"date": "Friday"}) ``` @@ -364,7 +376,7 @@ mcp.shared.exceptions.MCPError: Cannot send 'elicitation/create': this transport ```python async def main() -> None: - async with Client(mcp) as client: + async with Client("http://localhost:8000/mcp") as client: await client.call_tool("forecast", {"city": "London"}, request_state="round-1-from-worker-a") ``` @@ -417,7 +429,7 @@ mcp = MCPServer("Weather", request_state_security=RequestStateSecurity(keys=[key * `Tool already exists:` у лозі сервера — єдина ознака того, що два однойменні інструменти злилися в один. * Один 421, три написання: `Server returned an error response` (python `Client`), `421 Misdirected Request` / `Invalid Host header` (усе інше), `Invalid Host header: ` (лог сервера). Виправлення: `transport_security=TransportSecuritySettings(allowed_hosts=[...])`. * `Task group is not initialized` -> змонтований застосунок, чий хост-застосунок у своєму життєвому циклі так і не ввійшов у `mcp.session_manager.run()`. -* `Session not found` -> сервер перезапустився; перепід'єднайтеся. +* `Session not found` -> сервер перезапустився або сплив строк сесії (`session_idle_timeout`); перепід'єднайтеся. * `Cannot send 'elicitation/create': ... no back-channel ...` -> `ctx.elicit()` потребує каналу від сервера до клієнта: з'єднання `2026-07-28` його ніколи не має, `stateless_http=True` забирає канал старого покоління, а `json_response=True` — канал у межах запиту. Використовуйте резолвер (клієнту старого покоління також потрібен сервер, що зберігає канал). Сусідня помилка `Method not found` — це запит методу, якого немає в ревізії протоколу іншої сторони. * `Client did not declare the form elicitation capability ...` і `Elicitation not supported` -> клієнту бракує `elicitation_callback=`. * `Invalid or expired requestState` ніколи не пояснює причину в переданих даних. Лог сервера — пояснює; `unknown key` означає, що треба зробити `RequestStateSecurity(keys=[...])` спільним для всіх робочих процесів. diff --git a/i18n/uk/pages/whats-new.md b/i18n/uk/pages/whats-new.md index 02d1a39e..13296722 100644 --- a/i18n/uk/pages/whats-new.md +++ b/i18n/uk/pages/whats-new.md @@ -1,6 +1,6 @@ --- translation: - sections: [cfe01c0c5863dfa2, 1c58c5cfcc37d455, a7392996acf1ad8f, 875eb2889263424e] + sections: [cfe01c0c5863dfa2, 0dbb68d8b210177b, 80cf193023af3ed4, 875eb2889263424e] tool: 1 --- # Що нового у v2 {#whats-new-in-v2} @@ -42,11 +42,11 @@ mcp = MCPServer("Demo") # v1: FastMCP("Demo") v1 давав три вкладені шари: контекстний менеджер транспорту, що видає сирі потоки, обгорнуту навколо них `ClientSession` і викликаний вручну `await session.initialize()`. У v2 є один об'єкт: -```python title="client.py" hl_lines="14-18" ---8<-- "docs_src/client/tutorial001.py" +```python title="client.py" hl_lines="7-11" +--8<-- "docs_src/client/tutorial001_client.py" ``` -`Client` приймає об'єкт сервера (у пам'яті, без транспорту: це сценарій тестування), URL (Streamable HTTP), `StdioServerParameters` (підпроцес stdio) або будь-який інший контекстний менеджер транспорту, як-от `sse_client(...)`. Вхід в `async with` під'єднує та узгоджує версію протоколу, хай яким поколінням говорить сервер; після цього `client.server_capabilities` і `client.protocol_version` просто є, як і `client.server_info`, коли сервер себе ідентифікує (тепер це `Implementation | None`, бо ідентичність у поколінні 2026 необов'язкова). Колбеки семплювання й еліцитації, зареєстровані у v1, і далі працюють (їхні тіла зазнають того самого перейменування атрибутів у snake_case, що й усе інше на цій сторінці), тепер вони також відповідають на запити всередині результатів у стилі 2026 (нижче) і виконуються паралельно, а не по одному. `ClientSession` досі лежить під сподом для тих, кому потрібна низькорівнева поверхня, і `client.session` її віддає; вона теж змінилася (працює на новому рушії диспетчера, і деякі її власні сигнатури змінилися), тож прочитайте **[Посібник з міграції](migration.md#clientsession-now-runs-on-jsonrpcdispatcher-basesession-removed)**, перш ніж спускатися нижче. +`Client` приймає URL (Streamable HTTP), `StdioServerParameters` (підпроцес stdio), будь-який інший контекстний менеджер транспорту, як-от `sse_client(...)`, або, у тестах, сам об'єкт сервера (у пам'яті, без транспорту). Вхід в `async with` під'єднує та узгоджує версію протоколу, хай яким поколінням говорить сервер; після цього `client.server_capabilities` і `client.protocol_version` просто є, як і `client.server_info`, коли сервер себе ідентифікує (тепер це `Implementation | None`, бо ідентичність у поколінні 2026 необов'язкова). Колбеки семплювання й еліцитації, зареєстровані у v1, і далі працюють (їхні тіла зазнають того самого перейменування атрибутів у snake_case, що й усе інше на цій сторінці), тепер вони також відповідають на запити всередині результатів у стилі 2026 (нижче) і виконуються паралельно, а не по одному. `ClientSession` досі лежить під сподом для тих, кому потрібна низькорівнева поверхня, і `client.session` її віддає; вона теж змінилася (працює на новому рушії диспетчера, і деякі її власні сигнатури змінилися), тож прочитайте **[Посібник з міграції](migration.md#clientsession-now-runs-on-jsonrpcdispatcher-basesession-removed)**, перш ніж спускатися нижче. **[Клієнт](client/index.md)** знайомить із ним, **[Транспорти клієнта](client/transports.md)** описує чотири форми під'єднання, **[Колбеки клієнта](client/callbacks.md)** — самі колбеки, а **[Тестування](get-started/testing.md)** показує шаблон роботи в пам'яті, що замінює допоміжну функцію `create_connected_server_and_client_session()` з v1. @@ -171,11 +171,15 @@ v2 реалізує редакцію 2026-07-28 і обслуговує **оби Заміна розвертає виклик у зворотний бік. Інструмент, якому щось потрібно від користувача, *повертає* запитання (`InputRequiredResult`), клієнт відповідає на нього тими самими колбеками, які мав завжди, і виклик повторюється з прикріпленими відповідями. `Client` веде цей цикл за вас. На сервері ви рідко будуєте результат самі, бо це робить **[залежність](handlers/dependencies.md)**: анотуйте параметр `Resolve(ask_quantity)`, де `ask_quantity` — звичайна функція, яку ви пишете, і SDK запитає тим механізмом, який підтримує з'єднання: живим запитом еліцитації на сесії старого покоління або багатораундовим обміном на 2026. Одне тіло інструмента, обидва покоління: -```python title="dual_era.py" hl_lines="24 37-38" +```python title="server.py" hl_lines="21" --8<-- "docs_src/legacy_clients/tutorial001.py" ``` -Цей файл — уся ідея в одному місці: один сервер, один інструмент на основі `Resolve`, і клієнт старого покоління разом із сучасним клієнтом, які обидва отримують свою відповідь, у пам'яті. **[Багатораундові запити](handlers/multi-round-trip.md)** пояснює механізм (зокрема `request_state`, який SDK запечатує й перевіряє за вас); **[Еліцитація](handlers/elicitation.md)** — саме запитування. +```python title="client.py" hl_lines="14-15" +--8<-- "docs_src/legacy_clients/tutorial001_client.py" +``` + +Ці два файли — уся ідея в одному місці: один сервер, один інструмент на основі `Resolve`, і клієнт старого покоління разом із сучасним клієнтом, які обидва отримують свою відповідь від того самого запущеного сервера (**[Обслуговування клієнтів старого покоління](run/legacy-clients.md)** розбирає їх покроково). **[Багатораундові запити](handlers/multi-round-trip.md)** пояснює механізм (зокрема `request_state`, який SDK запечатує й перевіряє за вас); **[Еліцитація](handlers/elicitation.md)** — саме запитування. !!! warning "Це єдине місце, де перенесений сервер v1 змінює поведінку" Першими на це натрапляють ваші власні тести: `Client(mcp)` за замовчуванням узгоджує з вашим сервером v2 diff --git a/i18n/zh-hant/pages/advanced/apps.md b/i18n/zh-hant/pages/advanced/apps.md index 42fe76af..73884b17 100644 --- a/i18n/zh-hant/pages/advanced/apps.md +++ b/i18n/zh-hant/pages/advanced/apps.md @@ -1,6 +1,6 @@ --- translation: - sections: [0355618e5f4d5fe4, 1821eaf50f2d0b64, 82e0b28ebd3abf5a, 8ac39614c094f2d0, dab6ff945501ab2a, bd5565c3b2d4f959, 96819ce3d63a0487] + sections: [0355618e5f4d5fe4, 0fefa31fb7c7b585, 5c53e7487c9c70cc, 8ac39614c094f2d0, dab6ff945501ab2a, bd5565c3b2d4f959, 96819ce3d63a0487] tool: 1 --- # MCP Apps {#mcp-apps} @@ -18,7 +18,7 @@ SDK 以內建的 `Apps` 擴充功能(`io.modelcontextprotocol/ui`)提供這 ## 有錶面的時鐘 {#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" ``` @@ -39,11 +39,31 @@ HTML 本身會監聽主機的 `postMessage` 並顯示結果。真正的 app 請 模型讀的是 `content`;iframe 是給人看的。支援 UI 的主機照樣會把文字結果餵給模型,而純文字用戶端**只**會拿到那個。所以標準做法是一個工具,兩種答案。再看一次 `get_time`: -```python title="server.py" hl_lines="23-27" +```python title="server.py" hl_lines="21-25" --8<-- "docs_src/apps/tutorial001.py" ``` -只有當用戶端宣告了 `io.modelcontextprotocol/ui` 擴充功能,**而且**在它的 `mimeTypes` 設定裡列出 `text/html;profile=mcp-app` 時,`client_supports_apps(ctx)` 才會是 `True`。這個欄位是必填的,所以省略它的用戶端不算數。同一個檔案裡的 `main()` 宣告的正是這些:協商的用戶端那一半,於是回來的是豐富版的答案。 +只有當用戶端宣告了 `io.modelcontextprotocol/ui` 擴充功能,**而且**在它的 `mimeTypes` 設定裡列出 `text/html;profile=mcp-app` 時,`client_supports_apps(ctx)` 才會是 `True`。這個欄位是必填的,所以省略它的用戶端不算數。以下是協商中用戶端的那一半: + +```python title="client.py" hl_lines="8 12" +--8<-- "docs_src/apps/tutorial001_client.py" +``` + +透過 HTTP 提供 `server.py`,然後在第二個終端機執行用戶端: + +```console +uv run mcp run server.py --transport streamable-http +``` + +```console +python client.py +``` + +```text +2026-06-26T12:00:00Z +``` + +回來的是豐富版的答案。把 `extensions=[APPS_SUPPORT]` 從 `Client` 呼叫裡拿掉,同一個程式就會改印出 `The time is 2026-06-26T12:00:00Z.`,而這就是純文字用戶端所能看到的全部。 !!! warning 絕對不要把 `"[Rendered UI]"` 這類佔位文字當成唯一的內容回傳。如果後備文字沒有用,這個工具對每個純文字用戶端、對模型本身就都沒有用。好好寫那句話。 diff --git a/i18n/zh-hant/pages/advanced/extensions.md b/i18n/zh-hant/pages/advanced/extensions.md index d90e61c2..37c6e5fa 100644 --- a/i18n/zh-hant/pages/advanced/extensions.md +++ b/i18n/zh-hant/pages/advanced/extensions.md @@ -1,6 +1,6 @@ --- translation: - sections: [05891e7cc1938a13, b3c01a6af28c51ee, 7ffc91f5e38bdfe0, 717d3f235a8333a7, f471a13b2fe5d737, ed6af2df4b656dff] + sections: [05891e7cc1938a13, b3c01a6af28c51ee, 6eba6ce094f4417e, 125f28588c85dd7b, ddd8969b698ca11c, ed6af2df4b656dff] tool: 1 --- # 擴充功能 {#extensions} @@ -49,7 +49,7 @@ TypeError: Stamps.identifier must be a `vendor-prefix/name` string 最小的有用擴充功能是一個工具加上一個設定對映表: -```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" ``` @@ -57,17 +57,23 @@ TypeError: Stamps.identifier must be a `vendor-prefix/name` string * `settings()` 是在 `capabilities.extensions["com.example/stamps"]` 宣告的值。回傳 `{}`(預設值)表示宣告這個擴充功能但不帶任何設定。 * 擴充功能永遠不會拿到伺服器。它以資料的形式宣告貢獻,由 `MCPServer` 取用。沒有 `self.server` 可以修改。 -而 `main()` 就是證明,一個記憶體內用戶端直接連上 `mcp`: +透過 HTTP 提供服務,用戶端就是證明: -```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" +``` + +本頁每個 `server.py` 都用這個指令提供服務,每個 `client.py` 則在第二個終端機用 `python client.py` 與它並行執行。 + ### 提供自己的方法 {#serving-your-own-methods} 擴充功能可以註冊**新的請求方法**:屬於它自己的動詞,和規格定義的方法並列提供: -```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" ``` @@ -83,14 +89,15 @@ TypeError: Stamps.identifier must be a `vendor-prefix/name` string ### 用戶端這一側 {#the-client-side} -同一個檔案的 `main()` 就是完整的用戶端故事,兩半都在裡面: +用戶端是獨立的程式,用戶端故事的兩半都在裡面: -```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)])` 宣告這個擴充功能。這些宣告會變成 `ClientCapabilities.extensions`:在 2026-07-28 連線上,這個對映表隨著每個請求的 `_meta` 信封傳送,所以伺服器在**每一個**請求上都看得到它;在舊版連線上,它則搭著 `initialize` 交握傳送。伺服器程式碼不用在意是哪一種:`require_client_extension(ctx, ...)` 和 `ctx.session.check_client_capability(...)` 在兩條路徑上都會讀取正確的來源。 * 廠商方法要往下一層用 `client.session.send_request(...)`;`Client` 只會為規格動詞長出一級方法。`send_request` 接受任何 `Request` 子類別,所以廠商請求原樣傳遞即可。 +* `SearchRequest` 和它攜帶的兩個模型是這個擴充功能的線路契約,所以用戶端自己宣告一份。正式發布的擴充功能會把它們放進一個套件,讓兩端都匯入。 ### 攔截 `tools/call` {#intercepting-toolscall} @@ -109,12 +116,18 @@ TypeError: Stamps.identifier must be a `vendor-prefix/name` string ## 使用用戶端擴充功能 {#using-a-client-extension} -**用戶端擴充功能**是從使用端看的同一份契約:掛在單一識別碼之下的一組用戶端行為。把實例傳給 `Client(extensions=[...])`,然後照常呼叫工具: +**用戶端擴充功能**是從使用端看的同一份契約:掛在單一識別碼之下的一組用戶端行為。這裡的伺服器回應 `buy` 時給的不是貨品,而是一張待兌換的收據,而且只對宣告了這個擴充功能的用戶端這樣做: -```python title="client.py" hl_lines="66-68" +```python title="server.py" hl_lines="22-25" --8<-- "docs_src/extensions/tutorial006.py" ``` +在用戶端,把實例傳給 `Client(extensions=[...])`,然後照常呼叫工具: + +```python title="client.py" hl_lines="33-35" +--8<-- "docs_src/extensions/tutorial006_client.py" +``` + `call_tool("buy", ...)` 回傳普通的 `CallToolResult`,和其他所有呼叫一樣。擴充功能改變的是:伺服器現在可以用 `receipt` **結果形狀**來回應 `buy`,而不是最終結果,而 `Receipts` 會在 `call_tool` 回傳之前把它完成(這裡是透過後續呼叫兌換收據)。呼叫端的程式碼完全不用動。 拿掉這個擴充功能,這一切就不存在:伺服器的關卡會拒絕沒有宣告它的用戶端(錯誤 -32021),而來自跳過關卡的伺服器的認領形狀會驗證失敗,完全符合規格對無法辨識的 `resultType` 的要求。預設關閉,線路的兩端都是。 @@ -124,15 +137,15 @@ TypeError: Stamps.identifier must be a `vendor-prefix/name` string ```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} 繼承 `ClientExtension`,只覆寫需要的部分。三種貢獻類型,各有預設實作:`settings()`、`claims()` 和 `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" ``` * 識別碼遵循和伺服器相同的語法,在類別定義時驗證。 @@ -153,12 +166,18 @@ def notifications(self) -> Sequence[NotificationBinding[Any]]: ### 擴充功能動詞 {#extension-verbs} -擴充功能自己的請求方法不需要用戶端註冊。廠商請求型別繼承 `mcp.types.Request`,並透過 `client.session.send_request` 送出,如[提供自己的方法](#serving-your-own-methods)所示。多一件事:當某個參數鍵必須搭上 `Mcp-Name` 標頭時(像 tasks 這類擴充功能規格對它們的動詞有此要求),請求型別要宣告 `name_param`: +擴充功能自己的請求方法不需要用戶端註冊。廠商請求型別繼承 `mcp.types.Request`,並透過 `client.session.send_request` 送出,如[提供自己的方法](#serving-your-own-methods)所示。以一個伺服器為例,它的擴充功能提供一個和具名工作有關的動詞: -```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" ``` +用戶端多一件事:當某個參數鍵必須搭上 `Mcp-Name` 標頭時(像 tasks 這類擴充功能規格對它們的動詞有此要求),請求型別要宣告 `name_param`: + +```python title="client.py" hl_lines="20-23 28-29" +--8<-- "docs_src/extensions/tutorial007_client.py" +``` + 工作階段會在每一條送出路徑上把 `params["jobId"]` 鏡射到 `Mcp-Name`,而缺少值時會明確失敗,而不是默默省略必要的標頭。 ## 擴充功能不能做的事 {#what-an-extension-cannot-do} diff --git a/i18n/zh-hant/pages/advanced/low-level-server.md b/i18n/zh-hant/pages/advanced/low-level-server.md index 9e05b314..62bcfd4d 100644 --- a/i18n/zh-hant/pages/advanced/low-level-server.md +++ b/i18n/zh-hant/pages/advanced/low-level-server.md @@ -1,6 +1,6 @@ --- translation: - sections: [2c79b6338e09b7ac, 7edc43b3fae11314, 1086e77ce561cd7f, a3f71823df5efc31, 9fc7109f72201cae, d50fe7faead8cf68, 7bf25983df655b66, 6330e1f4c6029683, 2f1749c8c133fa1c, 8db7116fc8ddd0ee, ebc33704fbd74262, cd0e9c933350390e] + sections: [2c79b6338e09b7ac, 9d5d10a5f0405d0a, 1086e77ce561cd7f, a3f71823df5efc31, 9fc7109f72201cae, d50fe7faead8cf68, 7bf25983df655b66, 6330e1f4c6029683, 2f1749c8c133fa1c, 8db7116fc8ddd0ee, ebc33704fbd74262, 0fde3bcea081ba3a] tool: 1 --- # 低階 Server {#the-low-level-server} @@ -36,18 +36,22 @@ translation: ### 試試看 {#try-it} -這個沒有 Inspector 可用:`mcp dev` 和 `mcp run` 只接受 `MCPServer`。記憶體內的 `Client` 則不在乎;它接收低階 `Server` 的方式和接收 `MCPServer` 完全一樣: +`mcp dev` 和 `mcp run` 只接受 `MCPServer`,所以這個得自己提供服務。`server.py` 的最後一行用它建立一個普通的 ASGI 應用程式,再交給 uvicorn 執行: -```python title="main.py" +```console +uvicorn server:app --port 8000 +``` + +把 Inspector 或任何用戶端指向 `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) @@ -64,6 +68,8 @@ asyncio.run(main()) * `result.structured_content` 是 `None`。高階伺服器會幫你把 `-> str` 包成 `{"result": ...}`;在這裡,你沒建的東西,沒有人會替你建。 * `list_tools` 回傳的是**你**打出來的 schema,一字不差。高階版本每個屬性上都有 `"title": "Query"`,根部還有一個 `"title": "search_booksArguments"`:那是 Pydantic 的產物。在這一層,線路上有的東西,都是你放上去的。 +在測試中可以跳過 uvicorn 和連接埠:`Client(server)` 在處理程序內接收低階 `Server` 的方式和接收 `MCPServer` 完全一樣,**[測試](../get-started/testing.md)** 講的就是這個模式。 + ## 沒有人替你檢查 {#nothing-is-checked-for-you} `MCPServer` 會在函式執行之前就拒絕錯誤的引數,依照它產生的 schema 驗證這次呼叫(**[工具](../servers/tools.md)**)。 @@ -214,4 +220,4 @@ use Server.middleware to observe or wrap initialization * `add_request_handler(method, params_type, handler)` 可以服務任何方法。`initialize` 被保留。 * `Server` 公告的能力,由你註冊了哪些處理函式推導而來。 -`Client(server)` 對兩種伺服器一視同仁,因為它們**就是**同一個協定,這正是重點所在。再往下一層根本不是類別:是 **[中介軟體](middleware.md)**。 +用戶端對兩種伺服器一視同仁,因為它們**就是**同一個協定,這正是重點所在。再往下一層根本不是類別:是 **[中介軟體](middleware.md)**。 diff --git a/i18n/zh-hant/pages/advanced/pagination.md b/i18n/zh-hant/pages/advanced/pagination.md index aa46b278..b0b0bd1b 100644 --- a/i18n/zh-hant/pages/advanced/pagination.md +++ b/i18n/zh-hant/pages/advanced/pagination.md @@ -1,6 +1,6 @@ --- translation: - sections: [a9aba7a026c7bd85, ed32bda7ba9ae33a, 7e64cc5646abb91f, 22a0129ee78b3c63, d875373c06d8d2f9] + sections: [a9aba7a026c7bd85, 83e2a08b9d46a398, 9fd8a0aa384b3257, 22a0129ee78b3c63, d875373c06d8d2f9] tool: 1 --- # 分頁 {#pagination} @@ -29,9 +29,13 @@ translation: ### 試試看 {#try-it} -`Client(server)` 在記憶體內連線到低階 `Server` 的方式,和連到 `MCPServer` 完全一樣。 +`mcp run` 只接受 `MCPServer`,所以這個得自己提供服務。`server.py` 的最後一行從 `Server` 建出一個普通的 ASGI 應用程式,交給 uvicorn 執行: -不帶引數呼叫 `list_resources()`。會拿到十個資源,`book-1` 到 `book-10`,而 `next_cursor` 是字串 `"10"`。 +```console +uvicorn server:app --port 8000 +``` + +把任何用戶端(**[用戶端](../client/index.md)**,或 Inspector)指向 `http://localhost:8000/mcp`,不帶引數呼叫 `list_resources()`。會拿到十個資源,`book-1` 到 `book-10`,而 `next_cursor` 是字串 `"10"`。 用 `list_resources(cursor="10")` 把它交回去,第一個資源就是 `book-11`,新的 `next_cursor` 是 `"20"`。 @@ -41,7 +45,7 @@ translation: `Client` 上的每個 `list_*` 方法(`list_tools`、`list_resources`、`list_resource_templates`、`list_prompts`)都接受 `cursor=` 關鍵字引數。把分頁清單抓完只要一個 `while True`: -```python title="client.py" hl_lines="26-32" +```python title="client.py" hl_lines="9-15" --8<-- "docs_src/pagination/tutorial002.py" ``` @@ -49,7 +53,7 @@ translation: * 先 extend,**再**看 `next_cursor`:最後一頁也有資源。 * `next_cursor is None` 就是出口。其他任何值都原封不動直接放回 `cursor=`。 -執行它的 `main()`,會印出 `100 resources`:十頁、每頁十個,由一個從頭到尾不知道有十頁的迴圈接起來。 +讓 uvicorn 繼續提供 `server.py`,在第二個終端機執行 `python client.py`。會印出 `100 resources`:十頁、每頁十個,由一個從頭到尾不知道有十頁的迴圈接起來。 這和 **[用戶端](../client/index.md)** 為每個 `list_*` 動詞示範的迴圈是同一個,而且對不分頁的伺服器也沒有任何代價:第一個回應的 `next_cursor` 就是 `None`,迴圈只執行一次。 diff --git a/i18n/zh-hant/pages/client/caching.md b/i18n/zh-hant/pages/client/caching.md index bdd63b73..03ef804e 100644 --- a/i18n/zh-hant/pages/client/caching.md +++ b/i18n/zh-hant/pages/client/caching.md @@ -1,6 +1,6 @@ --- translation: - sections: [9e7b9a1710e5aeba, b74ca4c1d2ddddee, fa8714e61bf90c5a, 04db67a886b7271c, 857690fb8f876800] + sections: [9e7b9a1710e5aeba, 66a2e9acc9101d54, d3d25aa802f5145a, 04db67a886b7271c, 857690fb8f876800] tool: 1 --- # 快取提示 {#caching-hints} @@ -27,7 +27,7 @@ translation: 在低階的 `Server` 上,處理函式自己手動組出結果,而 `ttl_ms` / `cache_scope` 只是結果模型上的欄位。明確設定這些欄位的處理函式,永遠勝過建構子的對應表,而且是逐欄位比較: -```python title="server.py" hl_lines="10 16" +```python title="server.py" hl_lines="11 17" --8<-- "docs_src/caching/tutorial002.py" ``` @@ -41,10 +41,24 @@ translation: 在 2026-07-28 的工作階段(session)上,`Client` 會替你遵守這些提示:它內建一個回應快取,預設開啟。帶著 `ttlMs` 抵達的結果會被存起來,在 TTL 內完全相同的呼叫會直接由快取提供,不需要往返。**沒有**帶提示的結果不會被快取:沒有提示的結果會套用 `CacheConfig.default_ttl_ms`,它預設為 `0`(立刻過期),所以什麼都沒宣告的伺服器,看到的流量和以往一模一樣,一次呼叫就一次請求。 -```python title="client.py" hl_lines="33 35 38" +要親眼看看這個過程,就用 uvicorn 提供前一節的 `server.py`(它的最後一行會建立 ASGI 應用程式)。處理函式每次真正執行時都會印出一行: + +```console +uvicorn server:app --port 8000 +``` + +```python title="client.py" hl_lines="20 23 28" --8<-- "docs_src/caching/tutorial003.py" ``` +在第二個終端機執行 `python client.py`。它會印出第一個結果帶著的提示,處理函式的 `ttlMs` 和對應表的 `cacheScope` 並排: + +```text +1000 public +``` + +伺服器的終端機交代了剩下的部分:在 uvicorn 的請求記錄之間,`tools/list served` 出現了三次。 + 四次呼叫,三次抓取。第二次呼叫找到新鮮的項目,根本沒送到伺服器;把(注入的)時鐘撥過 TTL 之後,第三次又重新抓取;第四次則指定了 `cache_mode="refresh"`。這個關鍵字引數存在於五個會快取的動詞上(`list_tools`、`list_prompts`、`list_resources`、`list_resource_templates`、`read_resource`): * `"use"`(預設)如果有新鮮的項目就直接提供,沒有的話就抓取並存起來。 @@ -53,7 +67,7 @@ translation: 有一條規則凌駕於 `"use"` 之上:**帶有 `meta` 的呼叫一定會送到伺服器。**設定了 `meta` 的請求(進度 token、追蹤欄位)期待的是一個實際送上線路的請求,所以在 `cache_mode="use"` 下會被當成 `"refresh"` 處理:跳過快取讀取,而抓取回來的結果仍然會取代快取中的項目。`"bypass"` 和明確指定的 `"refresh"` 行為照舊。 -要完全關掉快取,就用 `Client(server, cache=None)` 建構:每次呼叫又都變回一次往返,而 `cache_mode` 雖然仍可接受,但不會有任何作用。 +要完全關掉快取,就在建構 `Client` 時傳入 `cache=None`:每次呼叫又都變回一次往返,而 `cache_mode` 雖然仍可接受,但不會有任何作用。 範圍也會自動遵守:`"private"` 項目綁定在快取的**分區(partition)**上(見下文),而 `"public"` 項目則可以選擇更廣的共用。此外,對通知點名的那些項目來說,**通知勝過 TTL**:`list_changed` 通知會逐出對應的快取清單,`resources/updated` 則會逐出恰好存在該 URI 下的快取讀取結果,不管它們有多新鮮。在 2026-07-28 連線上,這些通知是透過你用 `client.listen(...)` 開啟的 `subscriptions/listen` 串流送達的,而且逐出會在你的監看程式看到事件之前完成;詳情請見 **[訂閱](subscriptions.md)**。 diff --git a/i18n/zh-hant/pages/client/callbacks.md b/i18n/zh-hant/pages/client/callbacks.md index db0ad2f5..e121e763 100644 --- a/i18n/zh-hant/pages/client/callbacks.md +++ b/i18n/zh-hant/pages/client/callbacks.md @@ -1,6 +1,6 @@ --- translation: - sections: [adf3c545b5be46b6, 916cd3ab1c03f461, e9be7a8d0eb0a456, 565890a636288ecf, 6af7e49db9129ec3, 06b0238c174186af, 90c6043be435fcb0] + sections: [adf3c545b5be46b6, 916cd3ab1c03f461, 32ef568335dd95a7, 565890a636288ecf, 6af7e49db9129ec3, 06b0238c174186af, 0abc5ea5cb7ff6b3] tool: 1 --- # 用戶端回呼 {#client-callbacks} @@ -58,7 +58,7 @@ result.content # [TextContent(type='text', text='Card issued to Ada Lovelace.') 你送出一個 `tools/call`,伺服器回送一個 `elicitation/create`,由你的函式回答,全都發生在同一次工具呼叫之內。 !!! info - `Client(...)` 呼叫上的 `mode="legacy"` 是真的有作用。預設情況下 `Client(...)` 會協商出現代的協定路徑,而這條路徑沒有讓伺服器向用戶端發請求的反向通道(back-channel):`ctx.elicit` 在你的回呼有機會執行之前就失敗了。決定這件事的不是傳輸方式,而是協商出來的協定,記憶體內和透過 URL 連線都一樣。只要用戶端必須回應這類請求,就固定用 `mode="legacy"`;這一頁背後的每個測試都是這樣做的。完整說明請見 **[協定版本](../protocol-versions.md)**。 + `Client(...)` 呼叫上的 `mode="legacy"` 是真的有作用。預設情況下 `Client(...)` 會協商出現代的協定路徑,而這條路徑沒有讓伺服器向用戶端發請求的反向通道(back-channel):`ctx.elicit` 在你的回呼有機會執行之前就失敗了。決定這件事的不是傳輸方式,而是協商出來的協定。只要用戶端必須回應這類請求,就固定用 `mode="legacy"`;這一頁背後的每個測試都是這樣做的。完整說明請見 **[協定版本](../protocol-versions.md)**。 在 2026-07-28 的工作階段(session)上,回呼並沒有失效,只是餵給它的方式不同:當工具回傳帶有 `ElicitRequest` 的 `InputRequiredResult` 時,`Client` 會把那個項目分派給同一個 `elicitation_callback`,並替你重試這次呼叫。這個流程就是 **[多輪往返(multi-round-trip)請求](../handlers/multi-round-trip.md)**。 @@ -139,4 +139,4 @@ result.structured_content # {'result': ['elicitation']} * `sampling_callback` 和 `list_roots_callback` 的運作方式相同,但服務的是已棄用的功能;現代的伺服器改用多輪往返請求。 * `logging_callback` 和 `message_handler` 接收通知。它們不宣告任何東西。 -`Client(...)` 的第一個引數是一個傳輸物件。**[用戶端傳輸方式](transports.md)** 涵蓋了每一種。 +`Client(...)` 的第一個引數決定傳輸方式。**[用戶端傳輸方式](transports.md)** 涵蓋了每一種。 diff --git a/i18n/zh-hant/pages/client/index.md b/i18n/zh-hant/pages/client/index.md index 8bd6edee..8e88f950 100644 --- a/i18n/zh-hant/pages/client/index.md +++ b/i18n/zh-hant/pages/client/index.md @@ -1,6 +1,6 @@ --- translation: - sections: [ebef1e7a0df854f4, 8355cfaf1f76c9d5, 8e79141fc2985342, 46bdb07c7537e8a5, 80ce41579825a6fa, 5f0fa90494de8f65, 83d10514eaa62fa5, 9190555aa39a5d28, 84a4c9d8bf14dddb, 927d71cf40b58c30] + sections: [ebef1e7a0df854f4, 7e16449f66e7dfd6, eeb0682f7d2a1079, 5713f0196a34e6e7, 0e844597859e4248, 3a97d9195ddcc92e, 1da08c483e59c141, 84702cc6e0a1fd42, 8dee7a31c86ffc5c, 83a5bce168ef23d7] tool: 1 --- # 用戶端 {#the-client} @@ -11,13 +11,23 @@ Python 程式要和 MCP 伺服器對話,靠的就是 **`Client`**。 ## 你的第一個用戶端 {#your-first-client} -```python title="client.py" hl_lines="14-18" +用戶端需要有伺服器可以對話。這一頁的每段程式碼連的都是這間 Bookshop。把它存成 `server.py`,讓它透過 HTTP 持續執行: + +```python title="server.py" --8<-- "docs_src/client/tutorial001.py" ``` -最上面的伺服器只是讓你有東西可以連而已。用戶端就是標示出來的那五行。 +```console +uv run mcp run server.py --transport streamable-http +``` -* `Client(mcp)` 拿到的是**伺服器物件本身**。這就是記憶體內傳輸:沒有子處理程序、沒有連接埠、沒有 HTTP。這一頁的每個範例,以及你寫的每個測試,都是這樣連線的。 +這樣伺服器就在 `http://localhost:8000/mcp` 提供服務。用戶端是另一個獨立的程式。把它存成 `client.py`,在第二個終端機執行 `python client.py`: + +```python title="client.py" hl_lines="7-11" +--8<-- "docs_src/client/tutorial001_client.py" +``` + +* `Client("http://localhost:8000/mcp")` 拿到的是一個 **URL**,所以它透過 Streamable HTTP 連到你剛啟動的伺服器。 * `async with` 就是**生命週期**。進入時連線並協商;離開時斷線。沒有 `connect()` / `close()` 這種成對的方法,而且區塊結束後 `Client` 不能再重複使用。 * 在區塊內,連線的各項資訊已經以普通屬性的形式準備好了。 @@ -25,10 +35,10 @@ Python 程式要和 MCP 伺服器對話,靠的就是 **`Client`**。 `Client` 接受一個位置引數,並依它的型別決定傳輸方式: -* `MCPServer`(或低階的 `Server`)實例:在**同一個處理程序內**連線。 -* URL 字串(`Client("http://localhost:8000/mcp")`):Streamable HTTP,也就是正式環境的路徑。 -* `StdioServerParameters`:要當作**子處理程序**啟動的命令,透過它的 stdin 和 stdout 溝通。 +* URL 字串(`Client("http://localhost:8000/mcp")`):Streamable HTTP,也就是部署時使用的傳輸方式。 +* `StdioServerParameters`:要當作本機**子處理程序**啟動的命令,透過它的 stdin 和 stdout 溝通。 * 一個**傳輸**:任何可以 `async with ... as (read, write)` 的東西,例如用 `streamable_http_client(url, http_client=...)` 包住你自己的 HTTP 用戶端。 +* `MCPServer`(或低階的 `Server`)實例:在**同一個處理程序內**連線,沒有子處理程序,也沒有連接埠。這一種是給測試用的,**[測試](../get-started/testing.md)** 就是以它為基礎。 這一頁其餘的內容在這四種情況下完全相同。標頭、子處理程序、逾時,以及 `Transport` 協定另外有專屬的頁面:**[用戶端傳輸方式](transports.md)**。 @@ -48,11 +58,11 @@ Python 程式要和 MCP 伺服器對話,靠的就是 **`Client`**。 ## 列出工具 {#listing-tools} -```python title="client.py" hl_lines="15-20" +```python title="client.py" hl_lines="8-13" --8<-- "docs_src/client/tutorial002.py" ``` -`list_tools()` 回傳一個 `ListToolsResult`;工具在 `.tools` 裡。每一個都是 MCP 主機(host)會交給模型的完整定義: +`list_tools()` 回傳一個 `ListToolsResult`;工具在 `.tools` 裡。每一個都是 MCP 主機(host)會交給模型的完整定義。第一個是這樣: ```python tool.name # 'search_books' @@ -76,6 +86,8 @@ tool.description # 'Search the catalog by title or author.' 這份 schema 就是 UI 算繪引數表單所需的一切,也是模型產生合法引數所需的一切。 +第二個工具 `lookup_book` 註冊時沒有給 `title=`,所以它的 `tool.title` 是 `None`。 + !!! tip `title` 是選填的,所以把工具顯示給人看的 UI 得自己挑:有的話就用 `title`,沒有就用 `name`。`from mcp.shared.metadata_utils import get_display_name` 做的正是這件事,適用於工具、資源、資源範本和提示詞。 @@ -83,7 +95,7 @@ tool.description # 'Search the catalog by title or author.' `call_tool(name, arguments)` 會執行工具,並回傳一個 `CallToolResult`。 -```python title="client.py" hl_lines="27-34" +```python title="client.py" hl_lines="9-16" --8<-- "docs_src/client/tutorial003.py" ``` @@ -131,7 +143,7 @@ result.is_error # False 資源的動作是成組的:兩種列出的方式,一種讀取的方式。 -```python title="client.py" hl_lines="22-31" +```python title="client.py" hl_lines="9-18" --8<-- "docs_src/client/tutorial004.py" ``` @@ -145,7 +157,7 @@ result.is_error # False ## 提示詞 {#prompts} -```python title="client.py" hl_lines="15-20" +```python title="client.py" hl_lines="8-13" --8<-- "docs_src/client/tutorial005.py" ``` @@ -170,7 +182,7 @@ message.content # TextContent(type='text', text='Recommend one poetry book from 有自動完成處理函式的伺服器,可以在使用者輸入時自動完成提示詞和資源範本的引數。 -```python title="client.py" hl_lines="27-31" +```python title="client.py" hl_lines="9-13" --8<-- "docs_src/client/tutorial006.py" ``` @@ -183,21 +195,21 @@ message.content # TextContent(type='text', text='Recommend one poetry book from 每個 `list_*` 方法都接受 `cursor=` 關鍵字引數,每個結果都帶有 `next_cursor`。當 `next_cursor` 是 `None`,表示全部拿到了。 -```python title="client.py" hl_lines="22-30" +```python title="client.py" hl_lines="7-15" --8<-- "docs_src/client/tutorial007.py" ``` -這個迴圈對任何伺服器都正確。`MCPServer` 會一頁回傳全部,所以 `next_cursor` 是 `None`,迴圈只跑一次,這也是為什麼大部分程式碼從來不寫它。真正會分頁的伺服器,以及游標遵守的規則,請見 **[分頁](../advanced/pagination.md)**。 +`list_all_tools` 對任何伺服器都正確。`MCPServer` 會一頁回傳全部,所以 `next_cursor` 是 `None`,迴圈只跑一次,這也是為什麼大部分程式碼從來不寫它。真正會分頁的伺服器,以及游標遵守的規則,請見 **[分頁](../advanced/pagination.md)**。 ## 在測試中 {#in-tests} -不需要處理程序、不需要連接埠的 `Client(mcp)`,本身就已經是伺服器的測試工具了。 +這一頁的每個 `client.py` 都是透過 HTTP 連到 `server.py`。在測試裡可以跳過網路,直接把伺服器物件本身交給 `Client`:`from server import mcp`,然後 `Client(mcp)`。沒有處理程序、沒有連接埠,而上面的每個方法用起來都一樣。 -有一個建構子旗標是專為此設計的:`Client(mcp, raise_exceptions=True)`。它只對記憶體內連線有作用,而 **[測試](../get-started/testing.md)** 頁面會解釋它,並圍繞它建立整套模式。 +有一個建構子旗標是專為此設計的:`Client(mcp, raise_exceptions=True)`。它只對同一處理程序內的連線有作用,而 **[測試](../get-started/testing.md)** 頁面會解釋它,並圍繞它建立整套模式。 ## 重點回顧 {#recap} -* `Client(x)` 對伺服器物件以記憶體內方式連線,對 URL 字串透過 Streamable HTTP 連線,其他情況則透過傳輸連線。 +* `Client(x)` 對 URL 字串透過 Streamable HTTP 連線,對 `StdioServerParameters` 啟動子處理程序,對傳輸則直接進入,在測試中則接受伺服器物件本身。 * `async with` 就是整個生命週期。在裡面,`server_capabilities` 和 `protocol_version` 已經填好;伺服器有提供時,`server_info` 和 `instructions` 也是。 * `list_tools()` 給你每個工具的 `name`、`title`、`description` 和 `input_schema`。 * `call_tool()` 回傳給模型的 `content`、給程式碼的 `structured_content`,以及 `is_error`。會引發例外的工具是一個結果,不是例外。 diff --git a/i18n/zh-hant/pages/client/oauth-clients.md b/i18n/zh-hant/pages/client/oauth-clients.md index fd0cb76a..6e369060 100644 --- a/i18n/zh-hant/pages/client/oauth-clients.md +++ b/i18n/zh-hant/pages/client/oauth-clients.md @@ -1,6 +1,6 @@ --- translation: - sections: [c6899d3892bd9fa0, 79372cff3cc48a88, 63878d29e87c3e73, 13175843d3588af4, e7e2b9fd516f77de, 758f06399b513c1f, a05d7278487d610b] + sections: [c6899d3892bd9fa0, 79372cff3cc48a88, c2dae1ebe2ebd543, 13175843d3588af4, df06056fb16b3846, 758f06399b513c1f, a05d7278487d610b] tool: 1 --- # OAuth 用戶端 {#oauth-clients} @@ -76,18 +76,20 @@ translation: `Client` 第一次送出請求時,伺服器回應 `401`。provider 接手: -1. **探索。** 讀取 `WWW-Authenticate` 標頭,從 `/.well-known/oauth-protected-resource` 抓取伺服器的 Protected Resource Metadata,得知是哪個授權伺服器在保護這個資源,再去抓取**那個**伺服器的中繼資料。 +1. **探索。** 讀取 `WWW-Authenticate` 標頭,從 `/.well-known/oauth-protected-resource` 抓取伺服器的 Protected Resource Metadata,得知是哪個授權伺服器在保護這個資源,再去抓取**那個**伺服器的中繼資料。(較舊、沒有發布資源中繼資料的伺服器,則改向它自己的 origin 索取授權伺服器中繼資料。)無論哪種情況,中繼資料列出的 `issuer` 都必須就是它所屬的那個伺服器;不是的話一律拒絕。 2. **註冊。** 儲存庫裡什麼都沒有?它會用你的 `OAuthClientMetadata` 動態註冊,並把結果存起來。 3. **授權。** 產生 PKCE 配對和一個 `state`,組出授權 URL,await 你的 `redirect_handler`,接著 await 你的 `callback_handler` 取得授權碼。 4. **交換。** 拿授權碼換得 `OAuthToken`,存起來,然後帶著 `Authorization: Bearer ...` 重送你原本的請求。 之後它就很安靜。權杖從儲存庫拿出來用,過期的存取權杖用重新整理權杖更新,只有這些都行不通時才會重跑整個流程。 +有一條傳輸規則適用於所有這些請求:和它們所在的那個 MCP 請求一樣,重新導向只有在留在同一個 origin 且保持相同方法時才會跟隨(例如補上結尾斜線的 307/308),其他任何重新導向都視為那個 URL 沒有回應。 + 這些你一行都沒寫。還剩兩個關鍵字引數(`client_metadata_url` 和 `validate_resource_url`),這個檔案兩個都用不到。值得認識的是 `client_metadata_url`,下面有它專屬的一節。 ### 試試看 {#try-it} -這份文件裡的大多數範例都能用記憶體內的 `Client(server)` 檢驗。這個不行:整個流程的重點就是一個 HTTP `401`,而記憶體內的用戶端和它的伺服器之間根本沒有 HTTP。 +測試裡用的記憶體內 `Client(server)` 在這裡幫不上忙:整個流程的重點就是一個 HTTP `401`,而記憶體內的用戶端和它的伺服器之間根本沒有 HTTP。 儲存庫裡附有實際運作的版本。`examples/servers/simple-auth/` 會執行一個獨立的授權伺服器和一個受保護的 MCP 伺服器;`examples/clients/simple-auth-client/` 則是這一頁的用戶端長成的一個小型 CLI。它的 README 有那兩個指令:啟動伺服器、對著它們執行用戶端,就能看著上面四個步驟依序發生。 @@ -105,13 +107,14 @@ URL 必須是 HTTPS 且路徑不能是根路徑;否則在建構時就會引發 `ClientCredentialsOAuthProvider` 是同一個 `httpx2.Auth`,只是少了人: -```python title="client.py" hl_lines="4 27-33" +```python title="client.py" hl_lines="4 27-34" --8<-- "docs_src/oauth_clients/tutorial002.py" ``` 改變的地方: * 沒有 `OAuthClientMetadata`,沒有處理函式。傳入 `client_id` 和 `client_secret`;provider 會圍繞它們建出一筆最精簡的 `client_credentials` 註冊,並完全跳過動態註冊。 +* `issuer` 指明發出這組憑證的授權伺服器;用它的 `/.well-known/oauth-authorization-server` 文件回傳的 `issuer` 值。探索仍照上面那樣執行,但權杖請求永遠只會用**那個** issuer 的中繼資料來組裝;如果 MCP 伺服器指向別的地方,流程會停下並引發 `OAuthFlowError`。省略它已棄用,3.0 起會變成必填(見 **[已棄用的功能](../deprecated.md#deprecated-sdk-helpers)**);在那之前,provider 會發出警告,並使用探索找到的那個授權伺服器。 * `scope` 是以空格分隔的字串,也就是 OAuth 的線路格式。 * 下游的一切完全相同:同樣的 `TokenStorage`、同樣的 `httpx2.AsyncClient(auth=...)`、同樣的 `streamable_http_client`。 @@ -121,7 +124,7 @@ URL 必須是 HTTPS 且路徑不能是根路徑;否則在建構時就會引發 `client_secret` 要從環境變數或祕密管理工具讀取,絕對不要放進版本控制。 !!! info - `mcp.client.auth.extensions.client_credentials` 裡還有一個 provider:**`PrivateKeyJWTOAuthProvider`**,給用 JWT 而非共用 secret 來驗證的用戶端使用(`private_key_jwt`,也就是金鑰對與工作負載身分那一類)。它遵循同樣的模式:建構一個,放到 `auth=` 上。同一個模組還附了 `SignedJWTParameters` 和 `static_assertion_provider`,兩個用來建出其 assertion 的輔助工具。 + `mcp.client.auth.extensions.client_credentials` 裡還有一個 provider:**`PrivateKeyJWTOAuthProvider`**,給用 JWT 而非共用 secret 來驗證的用戶端使用(`private_key_jwt`,也就是金鑰對與工作負載身分那一類)。它遵循同樣的模式:建構一個(它接受同樣選用的 `issuer`),放到 `auth=` 上。同一個模組還附了 `SignedJWTParameters` 和 `static_assertion_provider`,兩個用來建出其 assertion 的輔助工具。 還有一種無人介入的情境:用戶端屬於某個企業,由企業的身分提供者(而非使用者)決定它可以連到哪些 MCP 伺服器。那是另一種授權類型,有自己的信任模型,也有自己的頁面:**[身分斷言](identity-assertion.md)**。 diff --git a/i18n/zh-hant/pages/client/session-groups.md b/i18n/zh-hant/pages/client/session-groups.md index 504fd1c1..d8ec72fd 100644 --- a/i18n/zh-hant/pages/client/session-groups.md +++ b/i18n/zh-hant/pages/client/session-groups.md @@ -1,6 +1,6 @@ --- translation: - sections: [09c857a25a9dc37a, 43bc6a76a243a50e, 0a716022a88768df, 4b7f78042bfcfff7, c112662e61b03315, 58974ba1f489a8b4, d18adbdbb835ea73] + sections: [09c857a25a9dc37a, 43bc6a76a243a50e, 0a716022a88768df, 4b7f78042bfcfff7, c112662e61b03315, 58974ba1f489a8b4, ed4d17e894864056] tool: 1 --- # 工作階段群組 {#session-groups} @@ -76,7 +76,7 @@ translation: ## 重點回顧 {#recap} * `ClientSessionGroup` 握有多條伺服器連線,並把它們的工具、資源和提示詞各自合併成一個 `dict`。 -* 每台伺服器呼叫一次 `connect_to_server(params)`。它接受傳輸參數,絕不是 `Client` 接受的伺服器物件或 URL。 +* 每台伺服器呼叫一次 `connect_to_server(params)`。它接受傳輸參數,絕不是 `Client` 接受的 URL 或 `Transport`。 * `group.call_tool(name, arguments)` 會替你轉送到擁有該工具的伺服器。 * 名稱在整個群組內必須唯一;兩台都有 `search` 工具的伺服器無法原樣共存。 * `component_name_hook=` 會改寫每個登記的名稱。dict 的鍵會變,線路上的名稱不變。 diff --git a/i18n/zh-hant/pages/client/transports.md b/i18n/zh-hant/pages/client/transports.md index a7f839d4..5d626ba6 100644 --- a/i18n/zh-hant/pages/client/transports.md +++ b/i18n/zh-hant/pages/client/transports.md @@ -1,6 +1,6 @@ --- translation: - sections: [9cac816674181eb0, 0700f337babcd4dd, 2bde0dd58cdf00f5, 40b4916d82eaf1d4, 3d0832f39b0d7059, dfa4446556badef0, 5bd93be2ab2ecb9c] + sections: [9cac816674181eb0, 7c157764133fea1f, 40b4916d82eaf1d4, 10d151f2cc75317f, 3d0832f39b0d7059, 92742ba36533633d, 0aeca6145e7bd302] tool: 1 --- # 用戶端傳輸方式 {#client-transports} @@ -11,30 +11,15 @@ translation: 每種傳輸方式的**伺服器**端(`mcp.run()` 做的事,以及你部署的東西)請見 **[執行伺服器](../run/index.md)**。 -## 記憶體內 {#in-memory} - -直接傳入伺服器物件本身: - -```python title="client.py" hl_lines="14" ---8<-- "docs_src/client_transports/tutorial001.py" -``` - -沒有子處理程序,沒有連接埠,線路上也沒有任何位元組。用戶端和伺服器是同一個處理程序裡的兩個物件,但呼叫仍然會經過真正的協定層:`search_books` 被列出、驗證、呼叫的方式,和透過 HTTP 時完全一樣。 - -所以它同時是兩樣東西: - -* **測試工具。** 這份說明文件裡的每個範例都是這樣跑過的,而 **[測試](../get-started/testing.md)** 那一頁整個模式就是圍繞它建立的。 -* **嵌入用的 API。** 自己建立伺服器的應用程式,不需要繞一圈網路就能呼叫它的工具。 - ## Streamable HTTP {#streamable-http} -傳入一個 URL 字串,得到的就是 **Streamable HTTP**,也就是部署時使用的那種傳輸方式: +傳入一個 URL 字串,得到的就是 **Streamable HTTP**,也就是部署時使用、也該優先選用的傳輸方式: ```python title="client.py" hl_lines="5" --8<-- "docs_src/client_transports/tutorial002.py" ``` -這就是完整的正式環境用戶端。`Client` 會替你把 URL 包進 `streamable_http_client(...)`,底下是一個依 MCP 需求設定好的 `httpx2.AsyncClient`:`follow_redirects=True`、connect/write/pool 的逾時為 30 秒,read 逾時則是 300 秒,因為伺服器可能會讓回應串流一直開著。 +這就是完整的正式環境用戶端。`Client` 會替你把 URL 包進 `streamable_http_client(...)`,底下是一個依 MCP 需求設定好的 `httpx2.AsyncClient`:connect/write/pool 的逾時為 30 秒,read 逾時則是 300 秒,因為伺服器可能會讓回應串流一直開著。 !!! check 建立好的 `Client` **還沒有**連線。建立只是選定傳輸方式;真正開啟它的是 `async with`。在進入之前就去拿連線,SDK 會直接告訴你: @@ -49,7 +34,7 @@ translation: 一旦需要 `Authorization` 標頭、cookie、proxy、mTLS,或不同的逾時,就自己建立 `httpx2.AsyncClient`,再交給 `streamable_http_client`: -```python title="client.py" hl_lines="8-14" +```python title="client.py" hl_lines="8-13" --8<-- "docs_src/client_transports/tutorial003.py" ``` @@ -70,7 +55,25 @@ translation: 所有跟 HTTP 有關的設定,現在都放在你傳入的那一個 `httpx2.AsyncClient` 上。 !!! info - `httpx2` 保留了熟悉的 `httpx` API,所以只要會用 `httpx`,就已經知道這裡的驗證、proxy、事件掛鉤、重試和連線數限制該怎麼做。SDK 沒有在上面加任何東西,也沒有拿掉任何東西。OAuth 也是從這裡接上的:`httpx2.AsyncClient(auth=OAuthClientProvider(...))`。整個流程請見 **[OAuth 用戶端](oauth-clients.md)**。 + `httpx2` 保留了熟悉的 `httpx` API,所以只要會用 `httpx`,就已經知道這裡的驗證、proxy、事件掛鉤、重試和連線數限制該怎麼做。SDK 沒有在上面加任何東西,也沒有拿掉任何東西,[重新導向的處理](#redirects)除外。OAuth 也是從這裡接上的:`httpx2.AsyncClient(auth=OAuthClientProvider(...))`。整個流程請見 **[OAuth 用戶端](oauth-clients.md)**。 + +### 重新導向 {#redirects} + +傳輸只會連到你給它的那個 URL,而且只限那個來源(origin)。 + +* 留在同一個 scheme、主機和連接埠上的 `307`/`308` 重新導向會跟隨,同一台主機上的 `http://` → `https://` 也會。這涵蓋了常見的 `/mcp` → `/mcp/` 結尾斜線重新導向。 +* 導向其他任何地方的重新導向則**不會**跟隨。呼叫會失敗,並出現: + + ```text + MCPError: Redirect to https://other.example.com/mcp not followed; use that URL as the endpoint if it is the intended server + ``` + + 如果那個 URL 就是你要的伺服器,把它寫進設定裡。如果不是,那就是伺服器或它前面的 proxy 設定有誤。 + +不管傳入哪個 `httpx2.AsyncClient` 都一樣:MCP 請求不會參考它的 `follow_redirects` 設定,不論設成哪個方向。SDK 的 OAuth provider 對自己發出的請求也套用同一條規則。 + +!!! tip + `Redirect to http://… not followed: it would downgrade this HTTPS endpoint to plain HTTP` 表示伺服器前面有一個它不知道的、負責終結 TLS 的 proxy,所以它發出的是 `http://` 重新導向。這要在伺服器端修正(**[部署與擴展](../run/deploy.md#behind-a-tls-terminating-proxy)**),或者改用訊息裡建議的那個確切的 `https://…/` URL。 ## stdio {#stdio} @@ -91,6 +94,18 @@ translation: 需要 API 金鑰的伺服器在那裡是找不到的。請用 `env=` 明確傳入;這些變數會疊加在允許清單之上。上面的 `BOOKSHOP_API_KEY` 做的就是這件事。 +## 記憶體內 {#in-memory} + +在測試裡沒有東西要部署,也沒有東西要啟動。直接傳入伺服器物件本身: + +```python hl_lines="14" +--8<-- "docs_src/client_transports/tutorial001.py" +``` + +沒有子處理程序,沒有連接埠,線路上也沒有任何位元組。用戶端和伺服器是同一個處理程序裡的兩個物件,但呼叫仍然會經過真正的協定層:`search_books` 被列出、驗證、呼叫的方式,和透過 HTTP 時完全一樣。**[測試](../get-started/testing.md)** 那一頁整個模式就是圍繞它建立的。 + +同樣的寫法也可以當成嵌入用的 API:自己建立伺服器的應用程式,不需要繞一圈網路就能呼叫它的工具。 + ## SSE {#sse} `mcp.client.sse` 裡的 `sse_client(url)` 是被 Streamable HTTP 取代的那個 HTTP 傳輸。要和還在講它的伺服器溝通,用同樣的方式包起來即可:`Client(sse_client("http://localhost:8000/sse"))`,但不要在它上面蓋任何新東西。 @@ -99,15 +114,16 @@ translation: 對 `Client` 來說,上面這些全都是同一種東西。 -**傳輸**是任何會產出一對 `(read, write)` 訊息串流的非同步 context manager:正式地說,就是 `mcp.client` 裡的 `Transport` 協定。`Client` 依型別解析它的引數:伺服器物件就在處理程序內連線,`str` 會變成 `streamable_http_client(url)`,`StdioServerParameters` 會變成 `stdio_client(params)`,其他任何東西則直接當成傳輸進入。最後這條規則就是為什麼 `stdio_client(...)`、`streamable_http_client(...)` 和 `sse_client(...)` 都能放進同一個位置,也是為什麼你可以自己寫一個。 +**傳輸**是任何會產出一對 `(read, write)` 訊息串流的非同步 context manager:正式地說,就是 `mcp.client` 裡的 `Transport` 協定。`Client` 依型別解析它的引數:`str` 會變成 `streamable_http_client(url)`,`StdioServerParameters` 會變成 `stdio_client(params)`,伺服器物件就在處理程序內連線,其他任何東西則直接當成傳輸進入。最後這條規則就是為什麼 `stdio_client(...)`、`streamable_http_client(...)` 和 `sse_client(...)` 都能放進同一個位置,也是為什麼你可以自己寫一個。 ## 重點回顧 {#recap} -* `Client(mcp)`(伺服器物件)在記憶體內連線。用在測試和嵌入。 * `Client("http://.../mcp")`(URL)透過 Streamable HTTP 連線,也就是正式環境用的傳輸方式。 * 標頭、驗證、proxy 和逾時都放在你傳給 `streamable_http_client(url, http_client=...)` 的 `httpx2.AsyncClient` 上。沒有 `headers=` 這個關鍵字引數。 +* 重新導向只在 URL 自己的來源內跟隨(結尾斜線的 `307`/`308`),外加同一台主機上的 `http`→`https`。其他的一律失敗並出現 `Redirect to … not followed`;把最終的 URL 寫進設定即可。 * stdio 是 `Client(StdioServerParameters(...))`。只有要把子處理程序的 stderr 導到別處時,才需要自己用 `stdio_client(...)` 包起來。 * 子處理程序拿到的是允許清單上的環境,不是你的環境;`env=` 會往上加。 +* `Client(mcp)`(伺服器物件)在記憶體內連線。用在測試裡,或是把伺服器嵌入建立它的那個應用程式。 * 傳輸就是任何可以 `async with x as (read, write)` 的東西。只要不是伺服器物件、URL 或 `StdioServerParameters`,`Client` 就會直接交給那個協定處理。 * 建立 `Client` 是選定傳輸方式。`async with` 才是開啟它。 diff --git a/i18n/zh-hant/pages/deprecated.md b/i18n/zh-hant/pages/deprecated.md index ca8137da..ad5540b8 100644 --- a/i18n/zh-hant/pages/deprecated.md +++ b/i18n/zh-hant/pages/deprecated.md @@ -1,11 +1,11 @@ --- translation: - sections: [490237e61c3a7a44, 01262a123ad9501d, 429db5b574a2ac08, e2d0d273fbd2d74b, 64ab0331e868f3d4, 6c8878ce2d1f6d56, 4068f23e371bf0b3, eaef75b8725bc931] + sections: [4c1dea378b2b1bf7, 01262a123ad9501d, 429db5b574a2ac08, e2d0d273fbd2d74b, 64ab0331e868f3d4, 6c8878ce2d1f6d56, c3d1099701156881, e185a1b8e53669f6] tool: 1 --- # 已棄用的功能 {#deprecated-features} -2026-07-28 規格讓五樣東西退場。SDK 仍然實作了其中每一項,而每一項現在都帶有**棄用警告**。另外有一個 SDK 輔助函式是因為自身的原因棄用,列在[最後](#deprecated-sdk-helpers)。 +2026-07-28 規格讓五樣東西退場。SDK 仍然實作了其中每一項,而每一項現在都帶有**棄用警告**。另外有幾項 SDK 層級的棄用是出於自身的原因,列在[最後](#deprecated-sdk-helpers)。 下表列出每一項已棄用的功能、它為什麼要退場,以及應該改用的替代做法。 @@ -125,11 +125,13 @@ warnings.filterwarnings("ignore", category=MCPDeprecationWarning) ## 已棄用的 SDK 輔助函式 {#deprecated-sdk-helpers} -這些不是規格變更,只是有了更好替代做法的 SDK 內部實作。它們用同樣的 `MCPDeprecationWarning` 發出警告,並會在 3.0 移除。 +這些不是規格變更,只是有了更好替代做法的 SDK 用法。它們用同樣的 `MCPDeprecationWarning` 發出警告,舊的寫法會在 3.0 移除。 | 已棄用項目 | 替代做法 | |---|---| | `FuncMetadata.call_fn_with_arg_validation()` | 先 `FuncMetadata.validate_arguments()`,再 `FuncMetadata.call_fn()`。只有直接操作 `FuncMetadata` 的程式碼(例如自訂的 `Tool` 子類別)才會呼叫過它。 | +| 沒有設定 `validate_token_resource=` 的 `AuthSettings(resource_server_url=...)` | 把它設好:`True` 會讓伺服器拒絕驗證器沒有回報為核發給 `resource_server_url` 的 bearer 權杖;`False` 表示你的驗證器會自己檢查權杖的 audience(請見 **[授權](run/authorization.md#a-token-verifier)**)。不設定時的行為等同 `False`;3.0 起,只要設了 `resource_server_url`,預設就是 `True`。 | +| 沒有傳入 `issuer=` 的 `ClientCredentialsOAuthProvider(...)` 或 `PrivateKeyJWTOAuthProvider(...)` | 傳入 `issuer=`,指明核發這組憑證的授權伺服器(請見 **[撰寫 OAuth 用戶端](client/oauth-clients.md#machine-to-machine)**)。少了它,就變成由 MCP 伺服器決定哪個授權伺服器會收到這組憑證;3.0 會把這個關鍵字引數改為必填。 | ## 重點回顧 {#recap} @@ -138,7 +140,7 @@ warnings.filterwarnings("ignore", category=MCPDeprecationWarning) * 棄用只是勸告性質:線路沒有變更,一切在 2026 之前的工作階段上都能繼續運作,而且你會看到明顯的 `MCPDeprecationWarning`(它是 `UserWarning`,所以預設就會顯示)。 * 取樣和根目錄還額外需要一條反向通道,而 2026-07-28 的工作階段沒有。在現代連線上,它們會先警告,再引發例外。 * `warnings.filterwarnings("ignore", category=MCPDeprecationWarning)` 會讓整個類別靜音;pytest 裡的 `"error::mcp.MCPDeprecationWarning"` 則把它變成測試失敗。 -* 有一個 SDK 輔助函式 `FuncMetadata.call_fn_with_arg_validation()` 另外單獨棄用,預計在 3.0 移除。 +* [SDK 層級的棄用](#deprecated-sdk-helpers)遵循同樣的規則:現在發出警告,3.0 移除舊的寫法。 * 新程式碼不應該建立在這些東西之上。 這份說明文件的其他每一頁教的都是目前的 API。 diff --git a/i18n/zh-hant/pages/get-started/first-steps.md b/i18n/zh-hant/pages/get-started/first-steps.md index af48ee7a..49a3fb44 100644 --- a/i18n/zh-hant/pages/get-started/first-steps.md +++ b/i18n/zh-hant/pages/get-started/first-steps.md @@ -1,6 +1,6 @@ --- translation: - sections: [0d6c05bcbf836bf3, 59a7b14eeefc68c1, 7114d8d6daba203f, e8bbb56a98ba7bc9, 5138010f6159901c, f78da7c7c363d4c6, 220a939cab348686] + sections: [0d6c05bcbf836bf3, 9a78b5f6b44b18ab, 7114d8d6daba203f, e8bbb56a98ba7bc9, bfd2fd1153e71dac, 1615a994ef071fdd, 65c599fae991f245] tool: 1 --- # 第一步 {#first-steps} @@ -17,7 +17,7 @@ translation: * **用戶端**位於主機內部,負責講 MCP。主機每連上一個伺服器,就執行一個用戶端。 * **伺服器**是你用這個 SDK 打造的東西。它把東西公開給用戶端,從不直接和模型溝通。 -伺服器由你來寫,主機是別人的產品。SDK 也提供一個 `Client`,用來測試你的伺服器,這一頁稍後就會出現。 +伺服器由你來寫,主機是別人的產品。SDK 也提供一個 `Client`,主機要透過 URL 連上伺服器、或把它當成子處理程序啟動,用的就是同一個類別。它在這一頁稍後就會出現,也是你測試伺服器的方式。 ## 三種基本元件 {#the-three-primitives} @@ -79,22 +79,20 @@ Inspector 透過 **stdio** 執行你的伺服器,這是 MCP 伺服器能使用 用戶端連線時,伺服器會宣告自己的**能力**:它會回應哪幾類請求。用戶端據此決定究竟該要求什麼。這份宣告不是你寫的,`MCPServer` 替你宣告好了。 -自己看看吧。SDK 的 `Client` 直接接受伺服器物件,並在**記憶體內**與它連線(沒有子處理程序,沒有連接埠): +自己看看吧。在一個終端機裡讓 `server.py` 透過 HTTP 持續執行: -```python -import asyncio +```console +uv run mcp run server.py --transport streamable-http +``` -from mcp import Client +再從另一個終端機把用戶端指向它: -from server import mcp +```python title="client.py" hl_lines="7-8" +--8<-- "docs_src/first_steps/tutorial001_client.py" +``` - -async def main() -> None: - async with Client(mcp) as client: - print(client.server_capabilities.model_dump(exclude_none=True)) - - -asyncio.run(main()) +```console +python client.py ``` ```text @@ -114,7 +112,7 @@ asyncio.run(main()) 注意少了什麼。`completions`(資源範本和提示詞的引數自動完成)需要你寫一個處理函式,這個伺服器沒有,所以這項能力不存在,守規矩的用戶端也不會去問。所有選用的東西都照這條規則:註冊了,能力就出現;**[自動完成](../servers/completions.md)** 會證明這一點。 !!! info - `Client(mcp)` 就是這份文件裡每個範例測試時用的同一個記憶體內用戶端,你也會用它來測試自己的。它有專屬的一整頁:**[測試](testing.md)**。 + 那個 `client.py` 是一個完整的 MCP 用戶端,它的專屬頁面是 **[用戶端](../client/index.md)**。測試時可以跳過終端機和連接埠,把伺服器物件本身直接交給 `Client`,也就是 `Client(mcp)`。那也有專屬的一整頁:**[測試](testing.md)**。 ## 你沒寫的東西 {#what-you-did-not-write} @@ -123,7 +121,7 @@ asyncio.run(main()) * JSON Schema。`a: int, b: int` **就是** `add` 的 schema。 * 請求處理函式。`tools/list`、`resources/read`、`prompts/get`:全都替你處理好了。 * 能力宣告。`MCPServer` 替你做了。 -* 任何一行協定。版本協商、JSON-RPC 訊框、能力交換:全都發生在 `mcp dev` 和 `Client(mcp)` 裡面,你完全沒看到。 +* 任何一行協定。版本協商、JSON-RPC 訊框、能力交換:全都發生在 `mcp dev` 和 `client.py` 裡面,你完全沒看到。 這個比例正是 SDK 的意義所在。 @@ -134,6 +132,6 @@ asyncio.run(main()) * 每種基本元件一個裝飾器:`@mcp.tool()`、`@mcp.resource(uri)`、`@mcp.prompt()`。名稱、描述和 schema 都來自函式。 * 帶 `{param}` 的 URI 會產生資源**範本**,和具體資源分開列出。 * 伺服器的**能力**會替你宣告好,而用戶端只會要求伺服器宣告過的東西。 -* `Client(mcp)` 在記憶體內連上伺服器物件:從第一天起就是你的測試工具。 +* `Client("http://localhost:8000/mcp")` 會和執行中的伺服器對話。改成把伺服器物件交給它,也就是 `Client(mcp)`,從第一天起它就是你的測試工具。 接下來是 **[連接真正的主機](real-host.md)**:把這個伺服器真的放進 Claude Desktop 或 IDE 裡。然後是 **[測試](testing.md)**:一頁、一個記憶體內用戶端,從此不用猜它到底能不能動。再之後,每種基本元件各有自己的一頁,從模型主導的那個開始:**[工具](../servers/tools.md)**。 diff --git a/i18n/zh-hant/pages/get-started/testing.md b/i18n/zh-hant/pages/get-started/testing.md index c37bac9d..f8a5d60b 100644 --- a/i18n/zh-hant/pages/get-started/testing.md +++ b/i18n/zh-hant/pages/get-started/testing.md @@ -1,13 +1,13 @@ --- translation: - sections: ['4926721070127497', c52a1de2b6b32f40, 8e792bf8c7489ec6, 627195f7159e24ef] + sections: [5d13c2f0ba42c0d2, c52a1de2b6b32f40, 8e792bf8c7489ec6, 38552ea228b0a04f] tool: 1 --- # 測試 {#testing} -Python SDK 附帶一個 `Client` 類別,內建**記憶體內傳輸**:把伺服器物件傳給它,它就會直接連上去。 +SDK 的 `Client` 類別,也就是連到 URL 或啟動子處理程序的同一個類別,也能在**記憶體內**連線:把伺服器物件傳給它,它就會直接和伺服器對話。 -沒有子處理程序,沒有連接埠,根本沒有傳輸層。概念和 FastAPI 的 `TestClient` 一樣。 +沒有子處理程序,沒有連接埠,線路上什麼都沒有。概念和 FastAPI 的 `TestClient` 一樣。 ## 基本用法 {#basic-usage} @@ -86,7 +86,7 @@ async def test_call_add_tool(client: Client): 測試裡就開著它。在正式環境的程式碼裡它沒有任何意義。 -## 預設為處理程序內連線 {#in-process-by-default} +## 預設為世代中立 {#era-neutral-by-default} !!! note `Client(mcp)` 以處理程序內的方式連線,而且預設是**世代中立**的:它會探測伺服器,選出合適的協定路徑。如果測試要驗證舊版特有的語意(取樣(sampling)或徵詢(elicitation)的推送、`message_handler`),就固定用 `mode="legacy"`,並且在那裡拿掉 `raise_exceptions=True`:舊版連線本來就不會淨化,而這個旗標會讓失敗在伺服器任務裡重新引發,而不是在你的測試裡。 diff --git a/i18n/zh-hant/pages/handlers/multi-round-trip.md b/i18n/zh-hant/pages/handlers/multi-round-trip.md index 2d9c5a09..4d97c8cc 100644 --- a/i18n/zh-hant/pages/handlers/multi-round-trip.md +++ b/i18n/zh-hant/pages/handlers/multi-round-trip.md @@ -1,6 +1,6 @@ --- translation: - sections: [74011e683045eea9, 9b64cc175c18b6a9, 4b41be4824030397, e3b1502da786ec33, 71e41161f143c6a9, 9ec2c1eeb8c36378, 8dd027377d46448b, f81491125dcbfe8b] + sections: [74011e683045eea9, 9b64cc175c18b6a9, 4b41be4824030397, e3b1502da786ec33, 71e41161f143c6a9, 9ec2c1eeb8c36378, b47667184ca5b516, f81491125dcbfe8b] tool: 1 --- # 多輪往返請求 {#multi-round-trip-requests} @@ -162,7 +162,7 @@ TTL、主體綁定和請求綁定都**不是** codec 的工作:不論哪個 co ## 2026-07-28 的結果型別 {#a-2026-07-28-result} -`InputRequiredResult` 只存在於協定版本 **2026-07-28**。記憶體內的 `Client(server)` 會替你協商;走線路時,`mode="auto"` 會探知它。連線之後,`client.protocol_version` 會告訴你拿到的是什麼。 +`InputRequiredResult` 只存在於協定版本 **2026-07-28**。`Client` 預設的 `mode="auto"` 在任何連線上都會探知它。連線之後,`client.protocol_version` 會告訴你拿到的是什麼。 !!! warning 2026 之前的工作階段沒有地方放 `InputRequiredResult`。在 `mode="legacy"` 的連線上從處理函式回傳一個,runner 無法把它序列化成協商好的版本;用戶端會拿回 `-32603`「Handler returned an invalid result」錯誤。同時服務兩個世代的伺服器,必須先檢查 `ctx.protocol_version` 再動用它。 diff --git a/i18n/zh-hant/pages/handlers/progress.md b/i18n/zh-hant/pages/handlers/progress.md index de2ba688..7f1d96d5 100644 --- a/i18n/zh-hant/pages/handlers/progress.md +++ b/i18n/zh-hant/pages/handlers/progress.md @@ -1,6 +1,6 @@ --- translation: - sections: [5315262fe26b33e1, 9d8e98840f1b78f0, 0284b215e85366c4, 8534d8dbb4053a70, 2966fac6fe697007] + sections: [5315262fe26b33e1, 9d8e98840f1b78f0, 52d6009a07e770ea, 8534d8dbb4053a70, 2966fac6fe697007] tool: 1 --- # 進度 {#progress} @@ -29,19 +29,17 @@ translation: 用戶端是**逐次呼叫**選擇加入的,做法是把 `progress_callback=` 傳給 `call_tool`: -```python title="client.py" hl_lines="7 16" +```python title="client.py" hl_lines="5 14" import anyio from mcp import Client -from server import mcp - async def show(progress: float, total: float | None, message: str | None) -> None: print(f"{message} ({progress}/{total})") async def main() -> None: - async with Client(mcp) as client: + async with Client("http://localhost:8000/mcp") as client: result = await client.call_tool( "import_catalog", {"urls": ["https://example.com/a.json", "https://example.com/b.json"]}, @@ -56,23 +54,27 @@ anyio.run(main) 回呼是一個 `async` 函式,接收的正是伺服器回報的內容:`progress`、`total`、`message`。 !!! info - `Client(mcp)` 在記憶體內直接連上伺服器物件,和 **[測試](../get-started/testing.md)** 那一頁用的是同一個用戶端。不管 `Client` 用哪種傳輸方式,`progress_callback` 都是同一個參數;接下來看到的**時序**則是記憶體內連線的。它會就地執行回呼,所以每一筆回報都會在 `call_tool` 回傳之前送達。換成真正的傳輸方式,通知會和結果競速,一個慢的回呼在 `call_tool` 回傳之後可能還在執行。 + 不管交給 `Client` 的是什麼,`progress_callback` 都是同一個參數:像這裡的 URL、一個 `StdioServerParameters`,或測試裡的伺服器物件。不過走真正的傳輸方式時要留意時序。每個通知都是在回應之外單獨送達的,所以一個慢的回呼在 `call_tool` 回傳之後可能還在執行。只有處理程序內的測試連線會就地執行回呼,並保證每一筆回報都先送達。 ### 試試看 {#try-it} -把 `client.py` 放在 `server.py` 旁邊,然後執行: +用 HTTP 提供 `server.py`,然後在第二個終端機執行用戶端: + +```console +uv run mcp run server.py --transport streamable-http +``` ```console python client.py ``` ```text -Imported https://example.com/a.json (1/2) -Imported https://example.com/b.json (2/2) +Imported https://example.com/a.json (1.0/2.0) +Imported https://example.com/b.json (2.0/2.0) {'result': 'Imported 2 records.'} ``` -伺服器上的每一個 `await ctx.report_progress(...)` 都變成用戶端上對 `show` 的一次呼叫,依序發生,而且兩行都在 `call_tool` 回傳**之前**印出。進度不會打包進結果裡;它在工具還在執行時就持續串流過來。 +伺服器上的每一個 `await ctx.report_progress(...)` 都變成用戶端上對 `show` 的一次呼叫,依序發生。進度不會打包進結果裡;它在工具還在執行時就持續串流過來。 !!! warning `progress_callback` 屬於那一次**呼叫**,不屬於 `Client`。沒有對應的建構子引數,因為不同的呼叫想要不同的回呼:這一次驅動下載進度條,下一次是一行記錄。 diff --git a/i18n/zh-hant/pages/protocol-versions.md b/i18n/zh-hant/pages/protocol-versions.md index 459a6b41..993fce45 100644 --- a/i18n/zh-hant/pages/protocol-versions.md +++ b/i18n/zh-hant/pages/protocol-versions.md @@ -1,6 +1,6 @@ --- translation: - sections: [478fd619e5f90ef8, aef094a00e44e248, bab8cbf3449fa7e9, df1809b15a58335b, 5f9d8c2336ed0239, f54974398e43ddef, b24443dd78584870] + sections: [d98e337bf28845e0, dd642acbba36f615, f547b3e76da19c94, 149513378588da5c, 4e149ed992046709, f54974398e43ddef, b24443dd78584870] tool: 1 --- # 協定版本 {#protocol-versions} @@ -11,9 +11,17 @@ MCP 有兩個世代。 你幾乎不需要在意這件事,因為 `Client` 會替你協商。這一頁談的是控制這件事的那一個建構子引數 `mode=`,以及需要改動它的三種情況。 +這一頁的每段程式碼都是一個 `client.py`,連線到 **[用戶端](client/index.md)** 那頁的 Bookshop `server.py`。先在一個終端機裡啟動那個伺服器: + +```console +uv run mcp run server.py --transport streamable-http +``` + +接著在第二個終端機裡用 `python client.py` 執行每一段程式碼。 + ## `mode="auto"` {#modeauto} -```python title="client.py" hl_lines="14-15" +```python title="client.py" hl_lines="7-8" --8<-- "docs_src/protocol_versions/tutorial001.py" ``` @@ -31,11 +39,11 @@ MCP 有兩個世代。 整個功能就這樣。一個 `Client`,任何世代的伺服器,程式碼裡不用分支。 !!! info - `MCPServer` 在每一種傳輸方式上都會回答 `server/discover`(記憶體內、stdio、Streamable HTTP),所以對你自己的伺服器,`auto` 永遠會落在 `2026-07-28`。退回機制只會在面對真正的 2026 之前的伺服器時觸發,而那正是你希望它觸發的時候。 + `MCPServer` 在每一種傳輸方式上都會回答 `server/discover`(Streamable HTTP、stdio,以及測試時用的處理程序內連線),所以對你自己的伺服器,`auto` 永遠會落在 `2026-07-28`。退回機制只會在面對真正的 2026 之前的伺服器時觸發,而那正是你希望它觸發的時候。 ## `mode="legacy"` {#modelegacy} -```python title="client.py" hl_lines="14" +```python title="client.py" hl_lines="7" --8<-- "docs_src/protocol_versions/tutorial002.py" ``` @@ -59,7 +67,7 @@ MCP 有兩個世代。 `mode` 也接受新世代的協定版本字串。目前這個集合剛好就是 `["2026-07-28"]`。 -```python title="client.py" hl_lines="14" +```python title="client.py" hl_lines="7" --8<-- "docs_src/protocol_versions/tutorial003.py" ``` @@ -90,7 +98,7 @@ ValueError: mode must be 'legacy', 'auto', or one of ['2026-07-28']; got '2025-0 所以把它留下來。`auto` 連線之後,`client.session.discover_result` 保存著伺服器送來的那份 `DiscoverResult`:它的 `supported_versions`、`capabilities`、`instructions`,以及伺服器蓋進結果 `_meta` 裡的身分。下次把它作為 `prior_discover=` 交回去: -```python title="client.py" hl_lines="15 17" +```python title="client.py" hl_lines="8 10" --8<-- "docs_src/protocol_versions/tutorial004.py" ``` diff --git a/i18n/zh-hant/pages/run/asgi.md b/i18n/zh-hant/pages/run/asgi.md index 32c34aeb..dc7eb217 100644 --- a/i18n/zh-hant/pages/run/asgi.md +++ b/i18n/zh-hant/pages/run/asgi.md @@ -1,6 +1,6 @@ --- translation: - sections: [1062ef792791488a, 4be2b831547184a9, 374b049e770385f2, b72f6947089e6de0, b172c9db7831bb31, 70b9ece244ca1b0c, cba78e052898c3f6, f06bdb541cb0b469, fb82d526320b7cc3] + sections: [1062ef792791488a, 4be2b831547184a9, 374b049e770385f2, b72f6947089e6de0, b172c9db7831bb31, 10394c6f16601638, cba78e052898c3f6, f06bdb541cb0b469, 6dc898ccc5a903f9] tool: 1 --- # 加到現有的應用程式中 {#add-to-an-existing-app} @@ -86,7 +86,7 @@ Starlette 的 `Host` 路由用法相同:把 `Mount("/", ...)` 換成 `Host("mc --8<-- "docs_src/asgi/tutorial004.py" ``` -現在用戶端連到 `/notes`,而不是 `/notes/mcp`。 +現在用戶端連到 `/notes/`,而不是 `/notes/mcp`。 ## 給瀏覽器用戶端的 CORS {#cors-for-browser-clients} @@ -127,4 +127,4 @@ Starlette 的 `Host` 路由用法相同:把 `Mount("/", ...)` 換成 `Host("mc * 瀏覽器用戶端需要 CORS:`allow_headers` 給 `Mcp-*` 請求標頭用,`expose_headers=["Mcp-Session-Id"]` 給回應用。 * `@mcp.custom_route()` 在 `/mcp` 旁邊加上普通、不經驗證的 HTTP 端點。 -一旦伺服器能透過真正的 URL 連到,**[用戶端](../client/index.md)** 就會用那個 URL 而不是伺服器物件來連線。 +一旦伺服器能透過真正的 URL 連到,**[用戶端](../client/index.md)** 就會用那個 URL 來連線。 diff --git a/i18n/zh-hant/pages/run/authorization.md b/i18n/zh-hant/pages/run/authorization.md index 1618cddc..52b0ce73 100644 --- a/i18n/zh-hant/pages/run/authorization.md +++ b/i18n/zh-hant/pages/run/authorization.md @@ -1,6 +1,6 @@ --- translation: - sections: [d62c13457fc4a534, 80e73abaca6e0652, d1dc4c54cd00ec9c, 14ad3bc7904036bb, 5225f127bc1b9c77, fe1626fdd5aad1da, 4556cb7ea1a04a31] + sections: [d62c13457fc4a534, 80e73abaca6e0652, 128a492d18295f64, 14ad3bc7904036bb, 54a6697833fcc1d9, fe1626fdd5aad1da, 811d083c1da8bcf6] tool: 1 --- # 授權 {#authorization} @@ -23,12 +23,12 @@ translation: 有效的權杖長什麼樣子,SDK 沒有任何預設立場。由你來告訴它,方法是實作 **`TokenVerifier`**: -```python title="server.py" hl_lines="12-14 19-24" +```python title="server.py" hl_lines="14-16 21-27" --8<-- "docs_src/authorization/tutorial001.py" ``` * `TokenVerifier` 是只有一個非同步方法的 protocol。`verify_token` 會拿到 `Authorization` 標頭裡的原始權杖,有效就回傳 **`AccessToken`**,無效就回傳 `None`。沒有別的需要實作。 -* 這個範例是在一張表裡查權杖。真實的實作會驗證 JWT 簽章,或呼叫授權伺服器的權杖內省(token introspection)端點。那段程式碼是你的,SDK 只負責呼叫它。 +* 這個範例是在一張表裡查權杖;每一筆都記錄了權杖是為哪個資源發出的。真實的實作會驗證 JWT 簽章,或呼叫授權伺服器的權杖內省(token introspection)端點,並在 `AccessToken.resource` 裡回報權杖是發給誰的(它的 `aud`)。那段程式碼是你的,SDK 只負責呼叫它。 * `token_verifier=` 和 `auth=` 永遠成對出現。只傳其中一個,`MCPServer(...)` 在服務任何請求之前就會引發 `ValueError`。 `AuthSettings` 是資源伺服器對外的門面: @@ -36,6 +36,10 @@ translation: * `issuer_url`:發出權杖的授權伺服器。 * `resource_server_url`:這個 MCP 端點的公開 URL。它指明權杖是給**哪一個**資源用的,也是探索文件所在的位置。 * `required_scopes`:每個權杖都必須帶有全部這些 scope。 +* `validate_token_resource`:拒絕任何 `AccessToken.resource` 不等於 `resource_server_url` 的權杖。設定了 `resource_server_url` 卻沒設定它,會發出警告(`MCPDeprecationWarning`)並視同 `False`;3.0 起,資源伺服器的預設值會改為 `True`。 + * 如果你的授權伺服器會把權杖綁定到用戶端請求的 `resource`(MCP 用戶端一定會送),就把它打開。讓 `resource_server_url` 與用戶端實際連線的 URL 完全一致。 + * 如果你的授權伺服器使用自己的 audience 識別碼(Auth0 的 API identifier、Entra 的應用程式 ID),就保持關閉,改在驗證器裡檢查 `aud`,遇到不是給這個伺服器的權杖就回傳 `None`。 + * 如果 `aud` 是一個清單,把等於 `resource_server_url` 的那一項放進 `resource`。 !!! tip SDK 儲存庫裡的 `examples/servers/simple-auth/` 有一個 `IntrospectionTokenVerifier`,會呼叫真實授權伺服器的 [RFC 7662](https://datatracker.ietf.org/doc/html/rfc7662) 端點。大多數正式環境的驗證器都是這個樣子。 @@ -85,7 +89,7 @@ translation: 在任何處理函式內,**`get_access_token()`** 就是驗證器為目前請求回傳的那個 `AccessToken`: -```python title="server.py" hl_lines="4 32-35" +```python title="server.py" hl_lines="4 35-38" --8<-- "docs_src/authorization/tutorial002.py" ``` @@ -117,6 +121,6 @@ SDK 給你的是資源伺服器這一半:驗證、公告、拒絕。它不提 * `token_verifier=` 和 `auth=AuthSettings(issuer_url=..., resource_server_url=..., required_scopes=[...])` 永遠成對出現。 * SDK 會在 `/.well-known/oauth-protected-resource/...` 發布 [RFC 9728](https://datatracker.ietf.org/doc/html/rfc9728) Protected Resource Metadata,並以 401 回應未驗證的請求,其 `WWW-Authenticate` 標頭會指向這份文件。整個探索機制就這樣。 * 在任何處理函式裡,`get_access_token()` 就是誰在呼叫。 -* 授權是 HTTP 層的事。`stdio` 和記憶體內用戶端永遠看不到它。 +* 授權是 HTTP 層的事。`stdio` 和記憶體內測試用戶端永遠看不到它。 用戶端那一半(探索你的授權伺服器並替你取得權杖)請見 **[OAuth 用戶端](../client/oauth-clients.md)**。至於不問使用者、而是直接**斷言**身分的用戶端,請見 **[身分斷言](../client/identity-assertion.md)**。 diff --git a/i18n/zh-hant/pages/run/deploy.md b/i18n/zh-hant/pages/run/deploy.md index 01f1ed3a..c500dcaa 100644 --- a/i18n/zh-hant/pages/run/deploy.md +++ b/i18n/zh-hant/pages/run/deploy.md @@ -1,6 +1,6 @@ --- translation: - sections: [28221886b198784f, f88ea1f1614f3a1d, ce926d686730b6d0, 3be24f8ad8bb5ab9, 3fad24032b2224ff, f25a7f860e579ecb, e758745df6fb7b0a] + sections: [28221886b198784f, f88ea1f1614f3a1d, 2e76f5cb9df15042, ce926d686730b6d0, 3be24f8ad8bb5ab9, 3fad24032b2224ff, f25a7f860e579ecb, 697b01d95080880d] tool: 1 --- # 部署與擴展 {#deploy-scale} @@ -42,6 +42,22 @@ translation: 在用戶端找不到這幾個字。`421` 是純文字的 HTTP 回應,不是 JSON-RPC 錯誤,所以 MCP 用戶端只會引發一個籠統的傳輸錯誤;它不喜歡的主機名稱只會出現在**伺服器**的記錄裡,就一則警告。剛部署好卻拒絕所有連線的伺服器,在證明是別的原因之前,就是 Host 允許清單的問題。**[疑難排解](../troubleshooting.md)** 也從這裡開始。 +## 在終止 TLS 的代理後面 {#behind-a-tls-terminating-proxy} + +如果 TLS 在代理(ingress、負載平衡器、Caddy、nginx)就結束,uvicorn 在它後面提供純 HTTP,就告訴 uvicorn 信任代理的 `X-Forwarded-*` 標頭: + +```console +uvicorn server:app --proxy-headers --forwarded-allow-ips='' +``` + +不這麼做的話,應用程式會以為自己是透過 `http://` 提供服務,它發出的任何重新導向(常見的是 `/mcp` → `/mcp/`)都會指向 `http://…`。Python 用戶端拒絕從 HTTPS 端點跟到純 HTTP,而且會明說: + +```text +MCPError: Redirect to http://mcp.example.com/mcp/ not followed: it would downgrade this HTTPS endpoint to plain HTTP. +``` + +用戶端的權宜之計是設定伺服器實際提供服務的精確 URL(`https://mcp.example.com/mcp/`,含結尾斜線),讓重新導向根本不發生。正解是上面那個旗標。`FORWARDED_ALLOW_IPS` 是環境變數的寫法;`*` 會信任每一跳,只有在除了代理之外沒有東西碰得到 uvicorn 時才正確。 + ## Worker,以及誰需要黏性 {#workers-and-who-has-to-be-sticky} 主機名稱能回應之後,就在後面放不只一個 worker。SDK 沒有這方面的設定;擴展 Starlette 應用程式的方式跟擴展任何 ASGI 應用程式一樣,把物件交給懂得 fork 的東西: @@ -154,6 +170,7 @@ python -c "import secrets; print(secrets.token_hex(32))" ## 重點回顧 {#recap} * 預設情況下,這個應用程式只回應送往 localhost 的請求。`transport_security=TransportSecuritySettings(allowed_hosts=[...], allowed_origins=[...])` 是上線的關卡:在傳入它之前,真正主機名稱後面的每個請求都是 `421`,原因只在伺服器記錄裡。 +* 在終止 TLS 的代理後面,用 `--proxy-headers --forwarded-allow-ips=...` 執行 uvicorn,否則它的重新導向會指向 `http://`,用戶端會拒絕跟隨。 * 在 2026-07-28 上沒有工作階段,負載平衡器也沒有東西可黏。`stateless_http=True` 是只給舊版用的開關,因為現代請求在那個旗標被讀到之前就已經分流並回應了。 * 預設的 `requestState` 金鑰是 `os.urandom(32)`,每個處理程序各自鑄造。送到不同 worker 的多輪往返重試會以 `-32602`「Invalid or expired requestState」失敗。 * 解法是 `RequestStateSecurity(keys=[...])` **加上**每個執行個體相同的伺服器名稱。名稱是權杖預設的 audience 宣告。相同的金鑰,相同的名稱。 diff --git a/i18n/zh-hant/pages/run/index.md b/i18n/zh-hant/pages/run/index.md index a0a3960b..2d4afdaf 100644 --- a/i18n/zh-hant/pages/run/index.md +++ b/i18n/zh-hant/pages/run/index.md @@ -1,6 +1,6 @@ --- translation: - sections: [fea8d769ff9edeba, ce8e2ad42f29ef71, 0d705efb19cf99c2, 2fd7cf825e6d2b2c, 9adc400e8c88e854, 318893ad8e2e9924, 6b63ab96b34476c0] + sections: [fea8d769ff9edeba, ce8e2ad42f29ef71, 0d705efb19cf99c2, 9fd2357154a5b7e7, 9adc400e8c88e854, 318893ad8e2e9924, 6b63ab96b34476c0] tool: 1 --- # 執行伺服器 {#running-your-server} @@ -71,6 +71,8 @@ Inspector 做的事和真正的主機一模一樣:把 `server.py` 當成子處 * `json_response=True`:每個 POST 都用單一 JSON 本體回應,而不是 SSE 串流。那個本體只裝得下回應本身,別的都沒有,所以在請求中途回頭呼叫用戶端的工具(`ctx.elicit()`、取樣(sampling))在這一段會引發 `NoBackChannelError`,而綁在進行中呼叫上的通知(`ctx.report_progress()` 的進度、每次呼叫的記錄訊息)會被丟棄;獨立的 `GET` 串流仍會承載不相關的那些。 * `stateless_http=True`:每個請求一個全新的傳輸,不追蹤工作階段(session)。 * `max_request_body_size`:可接受的最大請求本體,以位元組計。預設為 4 MiB;更大的請求在解析或建立工作階段之前就會收到 HTTP 413。只有在合法的 MCP 訊息超過這個大小時才調高它。 +* `session_idle_timeout`:舊版工作階段在沒有任何進行中的請求時可以閒置的秒數,超過後伺服器就把它關掉。預設為 1800。`None` 會停用它。請見 [工作階段存留時間與限制](legacy-clients.md#session-lifetime-and-limits)。 +* `max_sessions`:一個處理程序同時能持有多少個舊版工作階段。預設為 10 000。`None` 會移除這個限制。在同一節有說明。 * `event_store`、`retry_interval`、`transport_security`:可續傳性與 DNS 重新綁定防護。這些可以先放著,等到部署到 localhost 以外的地方再說;`transport_security` 在 **[部署與擴展](deploy.md)** 有說明。 !!! warning diff --git a/i18n/zh-hant/pages/run/legacy-clients.md b/i18n/zh-hant/pages/run/legacy-clients.md index edfbce63..cce6d297 100644 --- a/i18n/zh-hant/pages/run/legacy-clients.md +++ b/i18n/zh-hant/pages/run/legacy-clients.md @@ -1,6 +1,6 @@ --- translation: - sections: [3d1663c18edc824c, d4fd37009a13f03d, af9f398a5a8b679a, 470c2dd144294d69, 8e45827e6d24e8c8, 91dfd0ce98ebb03c] + sections: [3d1663c18edc824c, 90956965ae6a1ca1, af9f398a5a8b679a, 5ce83b1f9d88da62, 0d9b5d13fffc94e5, 8e45827e6d24e8c8, 91dfd0ce98ebb03c] tool: 1 --- # 服務舊版用戶端 {#serving-legacy-clients} @@ -18,15 +18,25 @@ SDK 依每個請求的 `MCP-Protocol-Version` 標頭來路由。標明 `2026-07- ## 一個處理函式,兩個世代 {#one-handler-both-eras} -下面是一個必須問使用者問題的工具,以及兩個世代的用戶端呼叫它: +下面是一個必須問使用者問題的工具: -```python title="server.py" hl_lines="24 37-38" +```python title="server.py" hl_lines="21" --8<-- "docs_src/legacy_clients/tutorial001.py" ``` `reserve` 需要一樣模型沒有提供的東西:要幾本。`Annotated[..., Resolve(ask_quantity)]` 就是工具宣告這件事的方式(完整說明請見 **[相依性](../handlers/dependencies.md)**)。`reserve` 裡沒有任何地方指名版本、檢查能力或做分支。 -兩個用戶端**同時**開著,連到同一個 `mcp` 物件。`mode="legacy"` 會執行 `initialize` 交握:正是 2026 之前的用戶端會開啟的那種連線。另一個用預設值,落在 `2026-07-28`。 +透過 HTTP 提供服務,下面是兩個世代的用戶端呼叫它: + +```console +uv run mcp run server.py --transport streamable-http +``` + +```python title="client.py" hl_lines="14-15" +--8<-- "docs_src/legacy_clients/tutorial001_client.py" +``` + +兩個用戶端**同時**開著,連到同一個正在執行的伺服器。`mode="legacy"` 會執行 `initialize` 交握:正是 2026 之前的用戶端會開啟的那種連線。另一個用預設值,落在 `2026-07-28`。從第二個終端機執行 `python client.py`: ```text 2025-11-25 {'result': "Reserved 2 of 'Dune'."} @@ -53,6 +63,31 @@ SDK 依每個請求的 `MCP-Protocol-Version` 標頭來路由。標明 `2026-07- !!! warning `event_store=` 看起來像解法,但不是。它是**可恢復性**(把漏掉的 SSE 事件重播給重新連回**同一個**工作階段的用戶端),不是工作階段儲存區。它永遠不會讓工作階段能從另一個處理程序存取到。 +## 工作階段的存活時間與上限 {#session-lifetime-and-limits} + +舊版工作階段不會永遠活著,單一處理程序也不會持有無限多個。有兩個設定控制這件事,兩者都是 `run()`、`streamable_http_app()` 和 `Server.streamable_http_app()` 上的關鍵字引數。現代(`2026-07-28`)連線和 `stateless_http=True` 都沒有工作階段,所以這兩個設定對它們都不適用。 + +| 設定 | 預設值 | 作用 | 用戶端看到什麼 | 關閉方式 | +|---|---|---|---|---| +| `session_idle_timeout` | `1800`(30 分鐘) | 關閉一個已經那麼久沒有任何進行中事項的工作階段。 | `404 Session not found`。必須重新 `initialize`。 | `None` | +| `max_sessions` | `10_000` | 超過這個數量就拒絕再開新的工作階段。既有的工作階段不受影響,也不會驅逐任何一個。 | `503 Too many open sessions`,JSON-RPC 錯誤碼 `-32603`。 | `None` | + +什麼算「進行中」: + +* 一條開著的 `GET` 串流。SDK 的用戶端會保持一條開著,所以已連線的用戶端,它的工作階段永遠不會過期。 +* 還在回應中的請求。執行時間超過逾時的工具呼叫不會被中斷,倒數要等它結束後才開始。 +* 沒有別的了。請求與請求之間,時鐘照跑。工作階段上的任何請求都會讓它重新起算,`ping` 也算。工作階段一旦過期,沒有任何東西能讓它復活。 + +用 `DELETE` 結束工作階段的用戶端會立刻釋放它。開頭請求被拒絕的用戶端也是。 + +```python +mcp.run(transport="streamable-http", session_idle_timeout=None, max_sessions=50_000) +``` + +兩種事件都會出現在伺服器記錄裡。過期是 `INFO` 層級的 `Session idle timeout`。拒絕開啟是 `WARNING` 層級的 `Refusing to open a new session: sessions are already open`。 + +上限是以處理程序為單位。四個 worker 時,上限是 `max_sessions` 的四倍,每個 worker 各自讓自己的工作階段過期。 + ## 唯一的開關:`stateless_http` {#the-one-knob-stateless_http} 如果黏性是你不願付的代價,能改的東西剛好只有一樣。 @@ -73,7 +108,7 @@ SDK 依每個請求的 `MCP-Protocol-Version` 標頭來路由。標明 `2026-07- `json_response=True` 不是那個開關,但它在**每個**舊版工作階段上都會付出一半同樣的代價:用單一 JSON 本體回應的 `POST` 沒有串流可供請求範圍的通道使用,所以請求途中的 `ctx.elicit()` 會引發同樣的 `NoBackChannelError`,綁在該請求上的通知則被丟掉。工作階段的獨立串流不受影響:不相關的通知照樣送達。 !!! check - 故意做錯一次。`reserve` 正是剛剛服務了兩個用戶端的那個工具。用 `stateless_http=True` 部署它,透過 HTTP 連上同樣的兩個用戶端,從各自呼叫它。 + 故意做錯一次。`reserve` 正是剛剛服務了兩個用戶端的那個工具。用 `stateless_http=True` 部署它,連上同樣的兩個用戶端,從各自呼叫它。 現代用戶端還是得到 `Reserved 2 of 'Dune'.`,現代路徑沒變。 diff --git a/i18n/zh-hant/pages/troubleshooting.md b/i18n/zh-hant/pages/troubleshooting.md index fb91a046..e7bc4717 100644 --- a/i18n/zh-hant/pages/troubleshooting.md +++ b/i18n/zh-hant/pages/troubleshooting.md @@ -1,6 +1,6 @@ --- translation: - sections: [2efaecdef109a5c5, fcacd3e66b8635a4, 25323d737dcf0261, 8a6e351ec756904d, 137454d469c867f5, 6392596bd6df54f0, 41126fa9c4fe432f, 480b6d7897e30ab4, d83bb682e708dde0, ebbed3449c499db4, 323ef84f6b4bebde, 30fd31be74169d9a, 656943c6cb567218, c2dc3b1007d2e987, 7cf5386b997d04e9, 0b59feed8384456e, 0cba47bae78d04eb, e4355f4c7cf4fb2e] + sections: [3d58228e81b99543, 170514ce901c4139, 17d61fad0a50d62b, 8a6e351ec756904d, 137454d469c867f5, 6392596bd6df54f0, 41126fa9c4fe432f, 480b6d7897e30ab4, d83bb682e708dde0, ebbed3449c499db4, 525cdf1755e29d4c, 30fd31be74169d9a, d2e88333d4f7841f, c2dc3b1007d2e987, d6eabf60cc366341, f798e815252852c2, 0cba47bae78d04eb, 2c218ba829abf74e] tool: 1 --- # 疑難排解 {#troubleshooting} @@ -13,6 +13,12 @@ translation: --8<-- "docs_src/troubleshooting/tutorial001.py" ``` +那幾則會從 `http://localhost:8000/mcp` 連上它,所以讓它以 HTTP 持續執行著: + +```console +uv run mcp run server.py --transport streamable-http +``` + 這一頁引用的錯誤都是真的:SDK 自己的測試套件會重現每一個。 ## `ExceptionGroup: unhandled errors in a TaskGroup (1 sub-exception)` {#exceptiongroup-unhandled-errors-in-a-taskgroup-1-sub-exception} @@ -23,7 +29,7 @@ translation: ```python async def main() -> None: - async with Client(mcp) as client: + async with Client("http://localhost:8000/mcp") as client: await client.read_resource("weather://Atlantis") ``` @@ -49,7 +55,7 @@ async def main() -> None: ```python async def main() -> None: - async with Client(mcp) as client: + async with Client("http://localhost:8000/mcp") as client: try: await client.read_resource("weather://Atlantis") except MCPError as e: @@ -65,7 +71,7 @@ async def main() -> None: ```python async def main() -> None: - client = Client(mcp) + client = Client("http://localhost:8000/mcp") tools = await client.list_tools() # RuntimeError ``` @@ -73,7 +79,7 @@ async def main() -> None: ```python async def main() -> None: - async with Client(mcp) as client: + async with Client("http://localhost:8000/mcp") as client: tools = await client.list_tools() ``` @@ -245,7 +251,7 @@ app = Starlette(routes=[Mount("/", app=mcp.streamable_http_app())], lifespan=lif ## `MCPError: Session not found` {#mcperror-session-not-found} -伺服器不認得用戶端送來的 `Mcp-Session-Id`,幾乎都是因為伺服器**重新啟動了**(或是你被導到另一個實例)。工作階段存在那一個處理程序的記憶體內。 +伺服器不認得用戶端送來的 `Mcp-Session-Id`。要嘛伺服器**重新啟動了**(或是你被導到另一個實例),要嘛工作階段**過期了**,因為在 `session_idle_timeout`(預設 30 分鐘)這段時間內沒有任何進行中的請求。請見 [工作階段的存活時間與限制](run/legacy-clients.md#session-lifetime-and-limits)。工作階段存在那一個處理程序的記憶體內。 沒有伺服器的 bug 可找。HTTP 回應是 `404`,而它的本文**就是** JSON-RPC,所以和上面的 `421` 不同,python `Client` 會原封不動地把這個顯示給你: @@ -255,9 +261,9 @@ app = Starlette(routes=[Mount("/", app=mcp.streamable_http_app())], lifespan=lif 修正是重新連線:離開 `async with Client(...)` 區塊,進入一個新的,它會協商出新的工作階段。對於長時間存活的用戶端,這表示在呼叫外面攔截 `MCPError`,遇到這個訊息就重新連線,而不是在已經死掉的工作階段裡重試。 -如果**沒有**重新啟動也發生,代表你跑了不只一個 worker 卻沒有黏性工作階段(sticky session):每個 worker 都有自己的工作階段表,所以導到錯誤 worker 的請求就會落到這裡。這件事和它的兩種修正(黏性路由,或 `stateless_http=True`)請見 **[部署與擴展](run/deploy.md)** 和 **[服務舊版用戶端](run/legacy-clients.md)**。 +如果**沒有**重新啟動、用戶端也沒有安靜那麼久卻還是發生,代表你跑了不只一個 worker 卻沒有黏性工作階段(sticky session):每個 worker 都有自己的工作階段表,所以導到錯誤 worker 的請求就會落到這裡。這件事和它的兩種修正(黏性路由,或 `stateless_http=True`)請見 **[部署與擴展](run/deploy.md)** 和 **[服務舊版用戶端](run/legacy-clients.md)**。 -對伺服器維運人員來說,對應的記錄行是 `Rejected request with unknown or expired session ID: `。它以 `INFO` 層級記錄,所以在常用的 `WARNING` 門檻下看不到。剛部署完看到它一陣陣冒出來是正常的;每個已連線的用戶端都在重新連線。 +對伺服器維運人員來說,對應的記錄行是 `Rejected request with unknown or expired session ID: `。它以 `INFO` 層級記錄,所以在常用的 `WARNING` 門檻下看不到。剛部署完看到它一陣陣冒出來是正常的;每個已連線的用戶端都在重新連線。如果是工作階段過期,那一行前面會先出現 `Session idle timeout`,同樣是 `INFO` 層級。 ## `MCPError: Method not found` {#mcperror-method-not-found} @@ -269,7 +275,13 @@ app = Starlette(routes=[Mount("/", app=mcp.streamable_http_app())], lifespan=lif 伺服器想問使用者一件事,而這個用戶端從沒說過它可以被問。 -徵詢(elicitation)解析器在已連線的用戶端沒有宣告表單徵詢時,會一開始就拒絕,而 `e.error.data` 會精確寫出缺了什麼: +這間 Bistro 在訂位前會透過解析器先問一聲: + +```python title="server.py" hl_lines="15-17 21" +--8<-- "docs_src/troubleshooting/tutorial007.py" +``` + +用它取代 Weather 伺服器來服務,再從一個沒有傳 `elicitation_callback` 的用戶端呼叫 `book_table`。解析器會一開始就拒絕,因為已連線的用戶端從沒宣告表單徵詢(elicitation),而 `e.error.data` 會精確寫出缺了什麼: ```json { @@ -283,7 +295,7 @@ app = Starlette(routes=[Mount("/", app=mcp.streamable_http_app())], lifespan=lif ```python async def main() -> None: - async with Client(mcp, elicitation_callback=handle_elicitation) as client: + async with Client("http://localhost:8000/mcp", elicitation_callback=handle_elicitation) as client: result = await client.call_tool("book_table", {"date": "Friday"}) ``` @@ -302,14 +314,14 @@ async def main() -> None: 處理函式試圖在請求途中聯繫用戶端,但這條連線上的這次呼叫沒有能承載伺服器發出請求的通道。有三種伺服器設定會讓呼叫落到這種處境。 -**`2026-07-28` 連線:任何傳輸方式,一律如此。** 現代協定完全沒有伺服器發起的請求,所以伺服器在送出任何東西之前就拒絕。工具裡的 `ctx.elicit()` 是遇到這個的典型方式(就在第一次記憶體內測試時,因為 `Client(server)` 不用交代就會協商出 `2026-07-28`),而傳入 `elicitation_callback=` 什麼都不會改變,因為根本沒有請求送到用戶端讓它回答: +**`2026-07-28` 連線:任何傳輸方式,一律如此。** 現代協定完全沒有伺服器發起的請求,所以伺服器在送出任何東西之前就拒絕。工具裡的 `ctx.elicit()` 是遇到這個的典型方式,通常是在那個工具的第一個記憶體內 **[測試](get-started/testing.md)** 裡,因為 `Client(mcp)` 不用交代就會協商出 `2026-07-28`。傳入 `elicitation_callback=` 什麼都不會改變,因為根本沒有請求送到用戶端讓它回答: ```python title="server.py" hl_lines="16" --8<-- "docs_src/troubleshooting/tutorial006.py" ``` ```python -async def main() -> None: +async def test_book_table() -> None: async with Client(mcp) as client: await client.call_tool("book_table", {"date": "Friday"}) ``` @@ -347,7 +359,7 @@ mcp.shared.exceptions.MCPError: Cannot send 'elicitation/create': this transport ```python async def main() -> None: - async with Client(mcp) as client: + async with Client("http://localhost:8000/mcp") as client: await client.call_tool("forecast", {"city": "London"}, request_state="round-1-from-worker-a") ``` @@ -400,7 +412,7 @@ mcp = MCPServer("Weather", request_state_security=RequestStateSecurity(keys=[key * 伺服器記錄裡的 `Tool already exists:` 是兩個同名工具合併成一個的唯一跡象。 * 一個 421,三種寫法:`Server returned an error response`(python `Client`)、`421 Misdirected Request` / `Invalid Host header`(其他所有東西)、`Invalid Host header: `(伺服器記錄)。修正:`transport_security=TransportSecuritySettings(allowed_hosts=[...])`。 * `Task group is not initialized` -> 掛載的應用程式,其外層生命週期從未進入 `mcp.session_manager.run()`。 -* `Session not found` -> 伺服器重新啟動了;重新連線。 +* `Session not found` -> 伺服器重新啟動了,或工作階段過期了(`session_idle_timeout`);重新連線。 * `Cannot send 'elicitation/create': ... no back-channel ...` -> `ctx.elicit()` 需要伺服器到用戶端的通道:`2026-07-28` 連線從來沒有,`stateless_http=True` 拿走了舊版的那條,`json_response=True` 拿走了請求範圍的那條。改用解析器(舊版用戶端還需要一個保留通道的伺服器)。它的鄰居 `Method not found` 則是請求了一個對方的協定修訂版沒有的方法。 * `Client did not declare the form elicitation capability ...` 和 `Elicitation not supported` -> 用戶端少了 `elicitation_callback=`。 * `Invalid or expired requestState` 在線路上從不說原因。伺服器記錄會說;`unknown key` 表示要在各 worker 之間共用 `RequestStateSecurity(keys=[...])`。 diff --git a/i18n/zh-hant/pages/whats-new.md b/i18n/zh-hant/pages/whats-new.md index 50a07665..bcdfc7fe 100644 --- a/i18n/zh-hant/pages/whats-new.md +++ b/i18n/zh-hant/pages/whats-new.md @@ -1,6 +1,6 @@ --- translation: - sections: [cfe01c0c5863dfa2, 1c58c5cfcc37d455, a7392996acf1ad8f, 875eb2889263424e] + sections: [cfe01c0c5863dfa2, 0dbb68d8b210177b, 80cf193023af3ed4, 875eb2889263424e] tool: 1 --- # v2 的新功能 {#whats-new-in-v2} @@ -37,11 +37,11 @@ mcp = MCPServer("Demo") # v1: FastMCP("Demo") v1 交給你的是三層巢狀結構:一個產出原始串流的傳輸 context manager、包在外面的 `ClientSession`,再加上手動呼叫的 `await session.initialize()`。v2 只有一個物件: -```python title="client.py" hl_lines="14-18" ---8<-- "docs_src/client/tutorial001.py" +```python title="client.py" hl_lines="7-11" +--8<-- "docs_src/client/tutorial001_client.py" ``` -`Client` 接受一個伺服器物件(記憶體內、沒有傳輸,也就是測試的做法)、一個 URL(Streamable HTTP)、一個 `StdioServerParameters`(stdio 子處理程序),或任何其他傳輸 context manager,例如 `sse_client(...)`。進入 `async with` 就會連線並協商協定版本,不管伺服器講的是哪個世代;之後 `client.server_capabilities` 和 `client.protocol_version` 就直接在那裡,伺服器有表明身分時 `client.server_info` 也在(它現在是 `Implementation | None`,因為 2026 世代的身分是選用的)。在 v1 註冊的取樣和徵詢回呼仍然有效(回呼本體會遇到跟本頁其他地方一樣的 snake_case 屬性改名),現在也會回應 2026 風格的「結果中夾帶請求」(見下文),而且是並行執行,不再一次一個。想要低階介面的人,`ClientSession` 仍在底下,`client.session` 會把它交給你;它也有變動(跑在新的分派器引擎上,自己的部分簽章也改了),所以往下鑽之前先讀 **[遷移指南](migration.md#clientsession-now-runs-on-jsonrpcdispatcher-basesession-removed)**。 +`Client` 接受一個 URL(Streamable HTTP)、一個 `StdioServerParameters`(stdio 子處理程序)、任何其他傳輸 context manager(例如 `sse_client(...)`),或者在測試裡直接接受伺服器物件本身(記憶體內、沒有傳輸)。進入 `async with` 就會連線並協商協定版本,不管伺服器講的是哪個世代;之後 `client.server_capabilities` 和 `client.protocol_version` 就直接在那裡,伺服器有表明身分時 `client.server_info` 也在(它現在是 `Implementation | None`,因為 2026 世代的身分是選用的)。在 v1 註冊的取樣和徵詢回呼仍然有效(回呼本體會遇到跟本頁其他地方一樣的 snake_case 屬性改名),現在也會回應 2026 風格的「結果中夾帶請求」(見下文),而且是並行執行,不再一次一個。想要低階介面的人,`ClientSession` 仍在底下,`client.session` 會把它交給你;它也有變動(跑在新的分派器引擎上,自己的部分簽章也改了),所以往下鑽之前先讀 **[遷移指南](migration.md#clientsession-now-runs-on-jsonrpcdispatcher-basesession-removed)**。 **[用戶端](client/index.md)** 介紹它,**[用戶端傳輸方式](client/transports.md)** 說明四種連線形式,**[用戶端回呼](client/callbacks.md)** 說明回呼本身,**[測試](get-started/testing.md)** 示範取代 v1 `create_connected_server_and_client_session()` 輔助函式的記憶體內模式。 @@ -166,11 +166,15 @@ v2 實作 2026-07-28 修訂版,而且**兩個**修訂版同時服務:同一 替代方案把呼叫反過來。需要向使用者要東西的工具**回傳**那個問題(`InputRequiredResult`),用戶端用一直都有的那些回呼回答它,然後帶著答案重試這次呼叫。那個迴圈 `Client` 會替你驅動。在伺服器上很少需要自己建那個結果,因為 **[相依性](handlers/dependencies.md)** 會做:用 `Resolve(ask_quantity)` 標註一個參數,其中 `ask_quantity` 是你寫的普通函式,SDK 就會用連線支援的機制去問,在舊版工作階段上是即時的徵詢請求,在 2026 上是多輪往返。一個工具本體,兩個世代: -```python title="dual_era.py" hl_lines="24 37-38" +```python title="server.py" hl_lines="21" --8<-- "docs_src/legacy_clients/tutorial001.py" ``` -這個檔案把整個賣點集中在一處:一個伺服器、一個以 `Resolve` 為後盾的工具,以及一個舊版用戶端加一個現代用戶端都拿到答案,全在記憶體內。**[多輪往返請求](handlers/multi-round-trip.md)** 解釋機制(包括 SDK 替你密封和驗證的 `request_state`);**[徵詢](handlers/elicitation.md)** 說明怎麼問。 +```python title="client.py" hl_lines="14-15" +--8<-- "docs_src/legacy_clients/tutorial001_client.py" +``` + +這兩個檔案就是整個賣點:一個伺服器、一個以 `Resolve` 為後盾的工具,以及一個舊版用戶端加一個現代用戶端,都從同一個執行中的伺服器拿到答案(**[服務舊版用戶端](run/legacy-clients.md)** 會帶你走過這兩個檔案)。**[多輪往返請求](handlers/multi-round-trip.md)** 解釋機制(包括 SDK 替你密封和驗證的 `request_state`);**[徵詢](handlers/elicitation.md)** 說明怎麼問。 !!! warning "這是移植後的 v1 伺服器唯一會改變行為的地方" 你自己的測試最先碰到:`Client(mcp)` 對 v2 伺服器預設協商 2026-07-28,所以呼叫 `ctx.elicit()` 的工具在 v1 通過的測試裡會失敗。把問題搬進 `Resolve(...)` 參數(跨世代可攜),或者如果真的想要推送行為,就把測試用戶端釘在 `mode="legacy"`。 @@ -197,7 +201,7 @@ v2 實作 2026-07-28 修訂版,而且**兩個**修訂版同時服務:同一 * **擴充功能是一等公民。** 伺服器和用戶端在反向 DNS 識別碼底下宣告選用的能力組合([SEP-2133](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2133));內建的 `Apps` 擴充功能(MCP Apps)是參考範例。**[擴充功能](advanced/extensions.md)** 和 **[MCP Apps](advanced/apps.md)**。 * **錯誤碼標準化了。** 找不到的資源是 `-32602`,URI 放在 `error.data`,新的規格保留碼則是 `-32020`(標頭不符)、`-32021`(缺少必要能力)和 `-32022`(不支援的協定版本)。**[疑難排解](troubleshooting.md)** 以確切的訊息為索引。 * **授權更不容易用錯了。** 用戶端會驗證隨授權碼回傳的 `iss`([RFC 9207](https://datatracker.ietf.org/doc/html/rfc9207);`callback_handler` 現在回傳 `AuthorizationCodeResult`),註冊時送出 `application_type`,而且絕不會對不同的授權伺服器重送憑證。企業那一角的新東西:[SEP-990](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/990) 身分斷言流程。**[遷移指南](migration.md)** 列出每一項 OAuth 變更;完整說明請見 **[用戶端的 OAuth](client/oauth-clients.md)** 和 **[身分斷言](client/identity-assertion.md)**。 -* **每個伺服器都可追蹤。** OpenTelemetry 以中介軟體的形式預設啟用:每個請求都有一個伺服器 span,在處理程序設定 exporter 之前完全沒有成本。兩端都跑 SDK 時,用戶端還會在 `_meta` 裡傳播 W3C trace context,所以追蹤會接起來。**[OpenTelemetry](run/opentelemetry.md)**。 +* **每個伺服器都可追蹤。** OpenTelemetry 以中介軟體的形式預設啟用:每個請求都有一個伺服器 span,在處理程序設定 exporter 之前完全沒有成本。兩端都使用 SDK 時,用戶端還會在 `_meta` 裡傳播 W3C trace context,所以追蹤會接起來。**[OpenTelemetry](run/opentelemetry.md)**。 ## 從 v1 升級? {#upgrading-from-v1} diff --git a/i18n/zh/pages/advanced/apps.md b/i18n/zh/pages/advanced/apps.md index f3bb1d4f..1cc37465 100644 --- a/i18n/zh/pages/advanced/apps.md +++ b/i18n/zh/pages/advanced/apps.md @@ -1,6 +1,6 @@ --- translation: - sections: [0355618e5f4d5fe4, 1821eaf50f2d0b64, 82e0b28ebd3abf5a, 8ac39614c094f2d0, dab6ff945501ab2a, bd5565c3b2d4f959, 96819ce3d63a0487] + sections: [0355618e5f4d5fe4, 0fefa31fb7c7b585, 5c53e7487c9c70cc, 8ac39614c094f2d0, dab6ff945501ab2a, bd5565c3b2d4f959, 96819ce3d63a0487] tool: 1 --- # MCP Apps {#mcp-apps} @@ -18,7 +18,7 @@ SDK 把它作为内置的 `Apps` 扩展(`io.modelcontextprotocol/ui`)提供 ## 一个带界面的时钟 {#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" ``` @@ -39,11 +39,31 @@ HTML 本身监听宿主的 `postMessage` 并显示结果。真正的应用请在 模型读的是 `content`;iframe 是给人看的。支持 UI 的宿主照样会把文本结果交给模型,而纯文本客户端**只**拿到那部分。所以标准模式是一个工具、两种回答。再看一遍 `get_time`: -```python title="server.py" hl_lines="23-27" +```python title="server.py" hl_lines="21-25" --8<-- "docs_src/apps/tutorial001.py" ``` -只有当客户端声明了 `io.modelcontextprotocol/ui` 扩展,**并且**在其 `mimeTypes` 设置里列出了 `text/html;profile=mcp-app` 时,`client_supports_apps(ctx)` 才为 `True`。这个字段是必填的,省略它的客户端不算数。同一文件里的 `main()` 声明的正是这些:协商中客户端的那一半,于是富结果就返回了。 +只有当客户端声明了 `io.modelcontextprotocol/ui` 扩展,**并且**在其 `mimeTypes` 设置里列出了 `text/html;profile=mcp-app` 时,`client_supports_apps(ctx)` 才为 `True`。这个字段是必填的,省略它的客户端不算数。下面是协商中客户端的那一半: + +```python title="client.py" hl_lines="8 12" +--8<-- "docs_src/apps/tutorial001_client.py" +``` + +通过 HTTP 提供 `server.py`,然后在另一个终端运行客户端: + +```console +uv run mcp run server.py --transport streamable-http +``` + +```console +python client.py +``` + +```text +2026-06-26T12:00:00Z +``` + +返回的是富结果。从 `Client` 调用里去掉 `extensions=[APPS_SUPPORT]`,同一个程序就会改为打印 `The time is 2026-06-26T12:00:00Z.`,这也是纯文本客户端所能看到的全部。 !!! warning 绝不要把 `"[Rendered UI]"` 这样的占位符当作唯一的内容返回。如果回退文本没用,这个工具对所有纯文本客户端乃至模型本身就都没用。把那句话写出来。 diff --git a/i18n/zh/pages/advanced/extensions.md b/i18n/zh/pages/advanced/extensions.md index 13be45f4..118616bf 100644 --- a/i18n/zh/pages/advanced/extensions.md +++ b/i18n/zh/pages/advanced/extensions.md @@ -1,6 +1,6 @@ --- translation: - sections: [05891e7cc1938a13, b3c01a6af28c51ee, 7ffc91f5e38bdfe0, 717d3f235a8333a7, f471a13b2fe5d737, ed6af2df4b656dff] + sections: [05891e7cc1938a13, b3c01a6af28c51ee, 6eba6ce094f4417e, 125f28588c85dd7b, ddd8969b698ca11c, ed6af2df4b656dff] tool: 1 --- # 扩展 {#extensions} @@ -49,7 +49,7 @@ TypeError: Stamps.identifier must be a `vendor-prefix/name` string 最小的有用扩展就是一个工具加一份设置映射: -```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" ``` @@ -57,17 +57,23 @@ TypeError: Stamps.identifier must be a `vendor-prefix/name` string * `settings()` 是在 `capabilities.extensions["com.example/stamps"]` 处声明的值。返回 `{}`(默认值)表示声明该扩展但不带任何设置。 * 扩展永远拿不到服务器。它以数据的形式声明贡献,由 `MCPServer` 消费。没有可供修改的 `self.server`。 -`main()` 就是证明:一个直接对着 `mcp` 的内存客户端: +用 HTTP 提供服务,一个客户端就是证明: -```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" +``` + +本页每个 `server.py` 都用这条命令启动,每个 `client.py` 都在另一个终端里用 `python client.py` 与它并行运行。 + ### 提供自己的方法 {#serving-your-own-methods} 扩展可以注册**新的请求方法**:它自己的动词,与规范定义的方法并列提供: -```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" ``` @@ -83,14 +89,15 @@ TypeError: Stamps.identifier must be a `vendor-prefix/name` string ### 客户端一侧 {#the-client-side} -同一个文件的 `main()` 就是客户端的全部内容,两半都在: +客户端是一个独立的程序,客户端这一侧的两半都在里面: -```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)])` 声明该扩展。这些声明会变成 `ClientCapabilities.extensions`:在 2026-07-28 连接上,该映射随每个请求的 `_meta` 信封传递,所以服务器在**每个**请求上都能看到它;在旧版连接上,它随 `initialize` 握手传递。服务器代码不用关心是哪一种:`require_client_extension(ctx, ...)` 和 `ctx.session.check_client_capability(...)` 在两条路径上都会读取正确的来源。 * 厂商方法要往下一层,用 `client.session.send_request(...)`;`Client` 只为规范动词提供一等方法。`send_request` 接受任何 `Request` 子类,所以厂商请求原样传入即可。 +* `SearchRequest` 和它携带的两个模型是这个扩展的线路约定,所以客户端自己也声明一份。公开发布的扩展会把它们放进一个双方都导入的包里。 ### 拦截 `tools/call` {#intercepting-toolscall} @@ -109,12 +116,18 @@ TypeError: Stamps.identifier must be a `vendor-prefix/name` string ## 使用客户端扩展 {#using-a-client-extension} -**客户端扩展**是从消费一侧看的同一份约定:一组归在同一个标识符之下的客户端行为。把实例传给 `Client(extensions=[...])`,然后照常调用工具: +**客户端扩展**是从消费一侧看的同一份约定:一组归在同一个标识符之下的客户端行为。这里的服务器回答 `buy` 时给的不是货物,而是一张待兑换的收据,而且只对声明了该扩展的客户端这样做: -```python title="client.py" hl_lines="66-68" +```python title="server.py" hl_lines="22-25" --8<-- "docs_src/extensions/tutorial006.py" ``` +在客户端,把实例传给 `Client(extensions=[...])`,然后照常调用工具: + +```python title="client.py" hl_lines="33-35" +--8<-- "docs_src/extensions/tutorial006_client.py" +``` + `call_tool("buy", ...)` 返回一个普通的 `CallToolResult`,和其他任何调用一样。扩展改变的是:服务器现在可以用 `receipt` **结果形态**而不是最终结果来回答 `buy`,`Receipts` 会在 `call_tool` 返回之前把它完成(这里是用一次后续调用兑换收据)。调用处什么都不用动。 去掉这个扩展,这一切就都不存在:服务器的门槛会拒绝没有声明它的客户端(错误 -32021),而跳过门槛的服务器发来的被认领形态会校验失败,正如规范对无法识别的 `resultType` 所要求的那样。默认关闭,线路两端都是。 @@ -124,15 +137,15 @@ TypeError: Stamps.identifier must be a `vendor-prefix/name` string ```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} 继承 `ClientExtension`,只重写需要的部分。贡献分三类,各有默认实现:`settings()`、`claims()` 和 `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" ``` * 标识符遵循与服务器端相同的语法,在类定义时校验。 @@ -153,12 +166,18 @@ def notifications(self) -> Sequence[NotificationBinding[Any]]: ### 扩展动词 {#extension-verbs} -扩展自己的请求方法不需要在客户端注册。厂商请求类型继承 `mcp.types.Request`,通过 `client.session.send_request` 发送,如[提供自己的方法](#serving-your-own-methods)所示。补充一点:当某个参数键必须放进 `Mcp-Name` 头(tasks 之类的扩展规范对其动词有此要求)时,请求类型要声明 `name_param`: +扩展自己的请求方法不需要在客户端注册。厂商请求类型继承 `mcp.types.Request`,通过 `client.session.send_request` 发送,如[提供自己的方法](#serving-your-own-methods)所示。以这样一个服务器为例,它的扩展提供一个与某个具名作业相关的动词: -```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" ``` +客户端上补充一点:当某个参数键必须放进 `Mcp-Name` 头(tasks 之类的扩展规范对其动词有此要求)时,请求类型要声明 `name_param`: + +```python title="client.py" hl_lines="20-23 28-29" +--8<-- "docs_src/extensions/tutorial007_client.py" +``` + 会话在每条发送路径上都会把 `params["jobId"]` 镜像到 `Mcp-Name` 中,值缺失时会明确报错,而不是悄悄漏掉一个必需的头。 ## 扩展不能做什么 {#what-an-extension-cannot-do} diff --git a/i18n/zh/pages/advanced/low-level-server.md b/i18n/zh/pages/advanced/low-level-server.md index c4200b0f..437905d3 100644 --- a/i18n/zh/pages/advanced/low-level-server.md +++ b/i18n/zh/pages/advanced/low-level-server.md @@ -1,6 +1,6 @@ --- translation: - sections: [2c79b6338e09b7ac, 7edc43b3fae11314, 1086e77ce561cd7f, a3f71823df5efc31, 9fc7109f72201cae, d50fe7faead8cf68, 7bf25983df655b66, 6330e1f4c6029683, 2f1749c8c133fa1c, 8db7116fc8ddd0ee, ebc33704fbd74262, cd0e9c933350390e] + sections: [2c79b6338e09b7ac, 9d5d10a5f0405d0a, 1086e77ce561cd7f, a3f71823df5efc31, 9fc7109f72201cae, d50fe7faead8cf68, 7bf25983df655b66, 6330e1f4c6029683, 2f1749c8c133fa1c, 8db7116fc8ddd0ee, ebc33704fbd74262, 0fde3bcea081ba3a] tool: 1 --- # 底层 Server {#the-low-level-server} @@ -36,18 +36,22 @@ translation: ### 试一试 {#try-it} -这个没有 Inspector 可用:`mcp dev` 和 `mcp run` 只接受 `MCPServer`。内存中的 `Client` 不在乎;它接收底层 `Server` 的方式和接收 `MCPServer` 完全一样: +`mcp dev` 和 `mcp run` 只接受 `MCPServer`,所以这个服务器要自己来跑。`server.py` 的最后一行用它构建了一个普通的 ASGI 应用,交给 uvicorn 运行: -```python title="main.py" +```console +uvicorn server:app --port 8000 +``` + +把 Inspector 或任何客户端指向 `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) @@ -64,6 +68,8 @@ asyncio.run(main()) * `result.structured_content` 是 `None`。高层服务器会替你把 `-> str` 包装成 `{"result": ...}`;在这里,你没构建的东西没人替你构建。 * `list_tools` 返回的是**你**敲进去的模式,一字不差。高层版本在每个属性上都有 `"title": "Query"`,根上还有一个 `"title": "search_booksArguments"`:Pydantic 的产物。在这一层,线路上有什么,都是你放上去的。 +在测试里可以跳过 uvicorn 和端口:`Client(server)` 在进程内接收底层 `Server` 的方式和接收 `MCPServer` 完全一样,**[测试](../get-started/testing.md)** 讲的就是这个模式。 + ## 没有替你做任何检查 {#nothing-is-checked-for-you} `MCPServer` 会在你的函数运行之前拒绝错误的参数,按它生成的模式校验调用(**[工具](../servers/tools.md)**)。 @@ -214,4 +220,4 @@ use Server.middleware to observe or wrap initialization * `add_request_handler(method, params_type, handler)` 提供任意方法。`initialize` 是保留的。 * `Server` 公布的能力由你注册了哪些处理函数推导而来。 -`Client(server)` 对两种服务器一视同仁,因为它们**就是**同一个协议,这正是关键所在。再往下一层根本不是一个类:它是 **[中间件](middleware.md)**。 +客户端对两种服务器一视同仁,因为它们**就是**同一个协议,这正是关键所在。再往下一层根本不是一个类:它是 **[中间件](middleware.md)**。 diff --git a/i18n/zh/pages/advanced/pagination.md b/i18n/zh/pages/advanced/pagination.md index b15bf411..3fd55ac3 100644 --- a/i18n/zh/pages/advanced/pagination.md +++ b/i18n/zh/pages/advanced/pagination.md @@ -1,6 +1,6 @@ --- translation: - sections: [a9aba7a026c7bd85, ed32bda7ba9ae33a, 7e64cc5646abb91f, 22a0129ee78b3c63, d875373c06d8d2f9] + sections: [a9aba7a026c7bd85, 83e2a08b9d46a398, 9fd8a0aa384b3257, 22a0129ee78b3c63, d875373c06d8d2f9] tool: 1 --- # 分页 {#pagination} @@ -29,9 +29,13 @@ translation: ### 试一试 {#try-it} -`Client(server)` 在内存中连接底层 `Server` 的方式,和连接 `MCPServer` 完全一样。 +`mcp run` 只接受 `MCPServer`,所以这个服务器得自己启动。`server.py` 的最后一行用这个 `Server` 构建出一个普通的 ASGI 应用,交给 uvicorn 运行: -不带参数调用 `list_resources()`。得到十个资源,从 `book-1` 到 `book-10`,`next_cursor` 是字符串 `"10"`。 +```console +uvicorn server:app --port 8000 +``` + +把任意客户端(**[客户端](../client/index.md)**,或者 Inspector)指向 `http://localhost:8000/mcp`,不带参数调用 `list_resources()`。得到十个资源,从 `book-1` 到 `book-10`,`next_cursor` 是字符串 `"10"`。 用 `list_resources(cursor="10")` 把它交回去,第一个资源就是 `book-11`,新的 `next_cursor` 是 `"20"`。 @@ -41,7 +45,7 @@ translation: `Client` 上的每个 `list_*` 方法(`list_tools`、`list_resources`、`list_resource_templates`、`list_prompts`)都接受一个 `cursor=` 关键字参数。取完一个分页列表只需要一个 `while True`: -```python title="client.py" hl_lines="26-32" +```python title="client.py" hl_lines="9-15" --8<-- "docs_src/pagination/tutorial002.py" ``` @@ -49,7 +53,7 @@ translation: * 先 extend,**再**看 `next_cursor`:最后一页也有资源。 * `next_cursor is None` 是出口。其他任何值都原封不动地直接塞回 `cursor=`。 -运行它的 `main()`,会打印 `100 resources`:十页、每页十个,由一个从头到尾都不知道有十页的循环拼在一起。 +让 uvicorn 继续运行 `server.py`,在第二个终端里运行 `python client.py`。它会打印 `100 resources`:十页、每页十个,由一个从头到尾都不知道有十页的循环拼在一起。 这和 **[客户端](../client/index.md)** 为每个 `list_*` 动词展示的是同一个循环,而且面对不分页的服务器也没有任何代价:第一个响应里 `next_cursor` 就是 `None`,循环只跑一次。 diff --git a/i18n/zh/pages/client/caching.md b/i18n/zh/pages/client/caching.md index 07c40df6..85f42d00 100644 --- a/i18n/zh/pages/client/caching.md +++ b/i18n/zh/pages/client/caching.md @@ -1,6 +1,6 @@ --- translation: - sections: [9e7b9a1710e5aeba, b74ca4c1d2ddddee, fa8714e61bf90c5a, 04db67a886b7271c, 857690fb8f876800] + sections: [9e7b9a1710e5aeba, 66a2e9acc9101d54, d3d25aa802f5145a, 04db67a886b7271c, 857690fb8f876800] tool: 1 --- # 缓存提示 {#caching-hints} @@ -27,13 +27,13 @@ translation: 在底层 `Server` 上,处理函数手动构建结果,`ttl_ms` / `cache_scope` 只是结果模型上的字段。显式设置了它们的处理函数总是逐字段地优先于构造函数映射: -```python title="server.py" hl_lines="10 16" +```python title="server.py" hl_lines="11 17" --8<-- "docs_src/caching/tutorial002.py" ``` 处理函数指定了 `ttl_ms=1_000`,对作用域只字未提。线路上是:`ttlMs: 1000`(处理函数的值,而不是映射里的 `60_000`)和 `cacheScope: "public"`(映射的值,因为处理函数没有设置)。显式优先于配置,配置优先于默认。这条规则按字段生效,所以处理函数可以固定一个字段,把另一个留给服务器范围的策略。 -这也是构造函数无法预知的动态情况的出口:一个按用户过滤 `resources/read` 的处理函数,可以在其他方面都是 public 的服务器上为某个 URI 返回 `cache_scope="private"`。 +这也是应对构造函数无法预知的动态情况的出口:一个按用户过滤 `resources/read` 的处理函数,可以在其他方面都是 public 的服务器上为某个 URI 返回 `cache_scope="private"`。 关于分页列表有一点要注意:协议要求同一列表的**每一页 `cacheScope` 相同**。构造函数映射天然满足这一点,因为它按方法而不是按页作键。但自行覆盖作用域的处理函数要自己负责这种一致性:在**每一**页都覆盖,绝不要只在有游标时覆盖,否则第一页和第二页会不一致。 @@ -41,10 +41,24 @@ translation: 在 2026-07-28 会话上,`Client` 替你遵从这些提示:它内置了响应缓存,默认开启。带着 `ttlMs` 到达的结果会被存起来,在 TTL 内的相同调用直接由缓存提供,不发生往返。**不**带提示的结果不会被缓存:无提示的结果使用 `CacheConfig.default_ttl_ms`,它默认为 `0`(立即过期),所以什么都没声明的服务器看到的流量和以前一模一样,一次调用对应一次请求。 -```python title="client.py" hl_lines="33 35 38" +想亲眼看看,就用 uvicorn 运行上一节的 `server.py`(它的最后一行构建了 ASGI 应用)。处理函数每次真正执行时都会打印一行: + +```console +uvicorn server:app --port 8000 +``` + +```python title="client.py" hl_lines="20 23 28" --8<-- "docs_src/caching/tutorial003.py" ``` +在另一个终端运行 `python client.py`。它打印出第一个结果携带的提示,处理函数的 `ttlMs` 挨着映射的 `cacheScope`: + +```text +1000 public +``` + +剩下的情况看服务器那边的终端:在 uvicorn 的请求日志之间,`tools/list served` 出现了三次。 + 四次调用,三次抓取。第二次调用找到了新鲜条目,根本没到服务器;把(注入的)时钟拨过 TTL 让第三次重新抓取;第四次指定了 `cache_mode="refresh"`。这个关键字参数存在于五个缓存动词上(`list_tools`、`list_prompts`、`list_resources`、`list_resource_templates`、`read_resource`): * `"use"`(默认)有新鲜条目就返回它,没有就抓取并存储。 @@ -53,7 +67,7 @@ translation: 有一条规则凌驾于 `"use"` 之上:**带 `meta` 的调用总会到达服务器。** 设置了 `meta`(进度令牌、追踪字段)的请求期望产生一次线路请求,所以在 `cache_mode="use"` 下它被当作 `"refresh"` 处理:跳过缓存读取,抓取到的结果仍会替换缓存条目。`"bypass"` 和显式的 `"refresh"` 行为照旧。 -要完全关闭缓存,用 `Client(server, cache=None)` 构造:每次调用重新变成一次往返,`cache_mode` 虽然仍被接受,但不起作用。 +要完全关闭缓存,构造 `Client` 时传入 `cache=None`:每次调用重新变成一次往返,`cache_mode` 虽然仍被接受,但不起作用。 作用域同样自动遵从:`"private"` 条目按缓存的**分区**(见下文)作键,而 `"public"` 条目可以选择更大范围的共享。并且对于通知点名的那些条目,**通知优先于 TTL**:`list_changed` 通知会驱逐对应的已缓存列表,`resources/updated` 会驱逐恰好存在其 URI 下的已缓存读取结果,不管它们多新鲜。在 2026-07-28 连接上,这些通知通过你用 `client.listen(...)` 打开的 `subscriptions/listen` 流到达,驱逐会在你的观察者看到事件之前完成;详见 **[订阅](subscriptions.md)**。 diff --git a/i18n/zh/pages/client/callbacks.md b/i18n/zh/pages/client/callbacks.md index 201c937c..35c6d137 100644 --- a/i18n/zh/pages/client/callbacks.md +++ b/i18n/zh/pages/client/callbacks.md @@ -1,6 +1,6 @@ --- translation: - sections: [adf3c545b5be46b6, 916cd3ab1c03f461, e9be7a8d0eb0a456, 565890a636288ecf, 6af7e49db9129ec3, 06b0238c174186af, 90c6043be435fcb0] + sections: [adf3c545b5be46b6, 916cd3ab1c03f461, 32ef568335dd95a7, 565890a636288ecf, 6af7e49db9129ec3, 06b0238c174186af, 0abc5ea5cb7ff6b3] tool: 1 --- # 客户端回调 {#client-callbacks} @@ -58,7 +58,7 @@ result.content # [TextContent(type='text', text='Card issued to Ada Lovelace.') 你发出一个 `tools/call`,服务器回过来一个 `elicitation/create`,由你的函数作答——全部发生在一次工具调用之内。 !!! info - `Client(...)` 调用里的 `mode="legacy"` 是真正起作用的。默认情况下 `Client(...)` 协商的是现代协议路径,而那条路径没有供服务器向客户端发请求的反向通道(back-channel):`ctx.elicit` 会在你的回调运行之前就失败。决定这一点的不是传输方式,而是协商出的协议,内存传输和 URL 传输都一样。只要你的客户端需要回答这类请求,就固定使用 `mode="legacy"`;本页背后的每个测试都是这么做的。详见 **[协议版本](../protocol-versions.md)**。 + `Client(...)` 调用里的 `mode="legacy"` 是真正起作用的。默认情况下 `Client(...)` 协商的是现代协议路径,而那条路径没有供服务器向客户端发请求的反向通道(back-channel):`ctx.elicit` 会在你的回调运行之前就失败。决定这一点的不是传输方式,而是协商出的协议。只要你的客户端需要回答这类请求,就固定使用 `mode="legacy"`;本页背后的每个测试都是这么做的。详见 **[协议版本](../protocol-versions.md)**。 在 2026-07-28 会话上,这个回调并没有失效,只是触发方式不同:当工具返回一个携带 `ElicitRequest` 的 `InputRequiredResult` 时,`Client` 会把该条目分派给同一个 `elicitation_callback`,并替你重试这次调用。那个流程见 **[多轮往返请求](../handlers/multi-round-trip.md)**。 @@ -139,4 +139,4 @@ result.structured_content # {'result': ['elicitation']} * `sampling_callback` 和 `list_roots_callback` 的工作方式相同,但服务的是已弃用的功能;现代服务器改用多轮往返请求。 * `logging_callback` 和 `message_handler` 接收通知。它们不声明任何东西。 -`Client(...)` 的第一个参数是传输对象。**[客户端传输](transports.md)** 涵盖了每一种。 +`Client(...)` 的第一个参数决定用哪种传输方式。**[客户端传输](transports.md)** 涵盖了每一种。 diff --git a/i18n/zh/pages/client/index.md b/i18n/zh/pages/client/index.md index f12c6334..aa421db5 100644 --- a/i18n/zh/pages/client/index.md +++ b/i18n/zh/pages/client/index.md @@ -1,6 +1,6 @@ --- translation: - sections: [ebef1e7a0df854f4, 8355cfaf1f76c9d5, 8e79141fc2985342, 46bdb07c7537e8a5, 80ce41579825a6fa, 5f0fa90494de8f65, 83d10514eaa62fa5, 9190555aa39a5d28, 84a4c9d8bf14dddb, 927d71cf40b58c30] + sections: [ebef1e7a0df854f4, 7e16449f66e7dfd6, eeb0682f7d2a1079, 5713f0196a34e6e7, 0e844597859e4248, 3a97d9195ddcc92e, 1da08c483e59c141, 84702cc6e0a1fd42, 8dee7a31c86ffc5c, 83a5bce168ef23d7] tool: 1 --- # Client {#the-client} @@ -11,13 +11,23 @@ translation: ## 你的第一个客户端 {#your-first-client} -```python title="client.py" hl_lines="14-18" +客户端需要一个可以对话的服务器。本页的每段代码连接的都是这个 Bookshop。把它保存为 `server.py`,让它通过 HTTP 一直运行着: + +```python title="server.py" --8<-- "docs_src/client/tutorial001.py" ``` -顶部的服务器只是为了让你有东西可连。客户端就是高亮的那五行。 +```console +uv run mcp run server.py --transport streamable-http +``` -* `Client(mcp)` 接收的是**服务器对象本身**。这是内存传输:没有子进程,没有端口,没有 HTTP。本页的每个示例,以及你写的每个测试,都是这样连接的。 +这样它就在 `http://localhost:8000/mcp` 上提供服务。客户端是一个独立的程序。把它保存为 `client.py`,在另一个终端里运行 `python client.py`: + +```python title="client.py" hl_lines="7-11" +--8<-- "docs_src/client/tutorial001_client.py" +``` + +* `Client("http://localhost:8000/mcp")` 接收的是一个 **URL**,所以它通过 Streamable HTTP 连接到你刚启动的服务器。 * `async with` 就是**生命周期**。进入时连接并协商;离开时断开。没有 `connect()` / `close()` 这样的配对方法,而且 `Client` 在代码块结束后不能复用。 * 在代码块内部,连接相关的信息已经作为普通属性摆在那里了。 @@ -25,10 +35,10 @@ translation: `Client` 接收一个位置参数,并根据它的类型确定传输方式: -* `MCPServer`(或低层 `Server`)实例:**进程内**连接。 -* URL 字符串(`Client("http://localhost:8000/mcp")`):Streamable HTTP,生产环境的路径。 -* `StdioServerParameters`:要作为**子进程**启动的命令,通过它的 stdin 和 stdout 通信。 +* URL 字符串(`Client("http://localhost:8000/mcp")`):Streamable HTTP,部署时所用的传输方式。 +* `StdioServerParameters`:要作为本地**子进程**启动的命令,通过它的 stdin 和 stdout 通信。 * **传输**:任何可以 `async with ... as (read, write)` 的对象,比如用 `streamable_http_client(url, http_client=...)` 包装你自己的 HTTP 客户端。 +* `MCPServer`(或低层 `Server`)实例:**进程内**连接,没有子进程,也没有端口。这一种是给测试用的,**[测试](../get-started/testing.md)** 就建立在它之上。 本页其余内容在这四种方式下完全相同。请求头、子进程、超时以及 `Transport` 协议另有专页:**[客户端传输](transports.md)**。 @@ -48,11 +58,11 @@ translation: ## 列出工具 {#listing-tools} -```python title="client.py" hl_lines="15-20" +```python title="client.py" hl_lines="8-13" --8<-- "docs_src/client/tutorial002.py" ``` -`list_tools()` 返回 `ListToolsResult`;工具在 `.tools` 里。每一个都是宿主会交给模型的完整定义: +`list_tools()` 返回 `ListToolsResult`;工具在 `.tools` 里。每一个都是宿主会交给模型的完整定义。下面是第一个: ```python tool.name # 'search_books' @@ -76,6 +86,8 @@ tool.description # 'Search the catalog by title or author.' UI 渲染参数表单所需的一切,以及模型生成合法参数所需的一切,都在这个模式里。 +第二个工具 `lookup_book` 注册时没有传 `title=`,所以它的 `tool.title` 是 `None`。 + !!! tip `title` 是可选的,所以把工具展示给人看的 UI 必须做选择:有 `title` 就用它,没有就用 `name`。`from mcp.shared.metadata_utils import get_display_name` 做的正是这件事,适用于工具、资源、资源模板和提示词。 @@ -83,7 +95,7 @@ UI 渲染参数表单所需的一切,以及模型生成合法参数所需的 `call_tool(name, arguments)` 运行工具,返回 `CallToolResult`。 -```python title="client.py" hl_lines="27-34" +```python title="client.py" hl_lines="9-16" --8<-- "docs_src/client/tutorial003.py" ``` @@ -131,7 +143,7 @@ result.is_error # False 资源动词成对出现:两种列出方式,一种读取方式。 -```python title="client.py" hl_lines="22-31" +```python title="client.py" hl_lines="9-18" --8<-- "docs_src/client/tutorial004.py" ``` @@ -145,7 +157,7 @@ result.is_error # False ## 提示词 {#prompts} -```python title="client.py" hl_lines="15-20" +```python title="client.py" hl_lines="8-13" --8<-- "docs_src/client/tutorial005.py" ``` @@ -170,7 +182,7 @@ message.content # TextContent(type='text', text='Recommend one poetry book from 带有补全处理函数的服务器可以在用户输入时自动补全提示词和资源模板的参数。 -```python title="client.py" hl_lines="27-31" +```python title="client.py" hl_lines="9-13" --8<-- "docs_src/client/tutorial006.py" ``` @@ -183,21 +195,21 @@ message.content # TextContent(type='text', text='Recommend one poetry book from 每个 `list_*` 方法都接收 `cursor=` 关键字参数,每个结果都带 `next_cursor`。`next_cursor` 为 `None` 时,说明已经拿全了。 -```python title="client.py" hl_lines="22-30" +```python title="client.py" hl_lines="7-15" --8<-- "docs_src/client/tutorial007.py" ``` -这个循环对任何服务器都正确。`MCPServer` 一页返回全部内容,所以 `next_cursor` 是 `None`,循环只跑一次,这也是为什么大多数代码从来不写它。真正分页的服务器,以及游标遵守的规则,见 **[分页](../advanced/pagination.md)**。 +`list_all_tools` 对任何服务器都正确。`MCPServer` 一页返回全部内容,所以 `next_cursor` 是 `None`,循环只跑一次,这也是为什么大多数代码从来不写它。真正分页的服务器,以及游标遵守的规则,见 **[分页](../advanced/pagination.md)**。 ## 在测试中 {#in-tests} -没有进程、没有端口的 `Client(mcp)`,本身就是服务器的测试工具。 +本页的每个 `client.py` 都是通过 HTTP 访问 `server.py` 的。在测试里可以跳过网络,把服务器对象本身交给 `Client`:先 `from server import mcp`,再 `Client(mcp)`。没有进程,没有端口,上面的每个方法用法都一样。 -有一个构造参数专为此而设:`Client(mcp, raise_exceptions=True)`。它只对内存连接生效,**[测试](../get-started/testing.md)** 页面会解释它,并围绕它搭建完整的模式。 +有一个构造参数专为此而设:`Client(mcp, raise_exceptions=True)`。它只对进程内连接生效,**[测试](../get-started/testing.md)** 页面会解释它,并围绕它搭建完整的模式。 ## 回顾 {#recap} -* `Client(x)` 传入服务器对象时走内存连接,传入 URL 字符串时走 Streamable HTTP,其他情况通过传输连接。 +* `Client(x)` 传入 URL 字符串时走 Streamable HTTP,传入 `StdioServerParameters` 时启动子进程,传入传输时直接进入它,在测试里则接收服务器对象本身。 * `async with` 就是全部生命周期。在它内部,`server_capabilities` 和 `protocol_version` 已经填好;服务器提供时,`server_info` 和 `instructions` 也已填好。 * `list_tools()` 给出每个工具的 `name`、`title`、`description` 和 `input_schema`。 * `call_tool()` 返回给模型的 `content`、给代码的 `structured_content`,以及 `is_error`。抛异常的工具是一个结果,不是异常。 diff --git a/i18n/zh/pages/client/oauth-clients.md b/i18n/zh/pages/client/oauth-clients.md index c57558d3..3efc0075 100644 --- a/i18n/zh/pages/client/oauth-clients.md +++ b/i18n/zh/pages/client/oauth-clients.md @@ -1,6 +1,6 @@ --- translation: - sections: [c6899d3892bd9fa0, 79372cff3cc48a88, 63878d29e87c3e73, 13175843d3588af4, e7e2b9fd516f77de, 758f06399b513c1f, a05d7278487d610b] + sections: [c6899d3892bd9fa0, 79372cff3cc48a88, c2dae1ebe2ebd543, 13175843d3588af4, df06056fb16b3846, 758f06399b513c1f, a05d7278487d610b] tool: 1 --- # OAuth 客户端 {#oauth-clients} @@ -76,18 +76,20 @@ translation: `Client` 第一次发送请求时,服务器回答 `401`。提供者接手: -1. **发现。** 它读取 `WWW-Authenticate` 头,从 `/.well-known/oauth-protected-resource` 获取服务器的受保护资源元数据,得知是哪个授权服务器在保护这个资源,再去获取**那个**服务器的元数据。 +1. **发现。** 它读取 `WWW-Authenticate` 头,从 `/.well-known/oauth-protected-resource` 获取服务器的受保护资源元数据,得知是哪个授权服务器在保护这个资源,再去获取**那个**服务器的元数据。(较旧的服务器如果没有发布资源元数据,则改为在它自己的源上请求授权服务器元数据。)无论哪种方式,元数据里的 `issuer` 都必须正是获取它所针对的那个服务器;其他任何值一律拒绝。 2. **注册。** 存储里什么都没有?它用你的 `OAuthClientMetadata` 动态注册,并把结果存起来。 3. **授权。** 它生成 PKCE 对和一个 `state`,构建授权 URL,await 你的 `redirect_handler`,然后 await 你的 `callback_handler` 拿到授权码。 4. **交换。** 它用授权码换来 `OAuthToken`,存起来,再带上 `Authorization: Bearer ...` 重放你最初的请求。 之后它就安静了。令牌从存储里取出,过期的访问令牌用刷新令牌刷新,只有这些都行不通时才会重新跑一遍流程。 +有一条传输规则适用于所有这些请求:和它们所嵌套的那个 MCP 请求一样,只有当重定向留在同一个源并且保持请求方法不变时(比如补尾部斜杠的 307/308)才会跟随,其他任何重定向都视为该 URL 没有应答。 + 这些你一行都没写。还剩两个关键字参数(`client_metadata_url` 和 `validate_resource_url`),这个文件都用不到。值得了解的是 `client_metadata_url`,下面单独有一节讲它。 ### 试一试 {#try-it} -这份文档里的大多数示例都可以用内存中的 `Client(server)` 验证。这个不行:整个流程的核心就是一个 HTTP `401`,而内存中的客户端和它的服务器之间没有 HTTP。 +测试里用的内存中的 `Client(server)` 在这里帮不上忙:整个流程的核心就是一个 HTTP `401`,而内存中的客户端和它的服务器之间没有 HTTP。 仓库里附带了可实际运行的版本。`examples/servers/simple-auth/` 运行一个独立的授权服务器和一个受保护的 MCP 服务器;`examples/clients/simple-auth-client/` 是本页的客户端扩展成的一个小 CLI。它的 README 里有两条命令:启动服务器,对着它们运行客户端,就能看到这四个步骤依次走过。 @@ -105,13 +107,14 @@ URL 必须是 HTTPS 且路径不能是根路径;否则在构造时就是 `Valu `ClientCredentialsOAuthProvider` 是同一个 `httpx2.Auth`,只是去掉了人: -```python title="client.py" hl_lines="4 27-33" +```python title="client.py" hl_lines="4 27-34" --8<-- "docs_src/oauth_clients/tutorial002.py" ``` 变了什么: * 没有 `OAuthClientMetadata`,没有处理函数。传入 `client_id` 和 `client_secret`;提供者围绕它们构建一个最小的 `client_credentials` 注册,完全跳过动态注册。 +* `issuer` 指明颁发这些凭据的授权服务器;使用它的 `/.well-known/oauth-authorization-server` 文档返回的 `issuer` 值。发现仍按上面的方式进行,但令牌请求只会基于**那个**颁发者的元数据构建;如果 MCP 服务器指向别处,流程会以 `OAuthFlowError` 中止。省略它已弃用,到 3.0 会变为必填(见 **[已弃用的功能](../deprecated.md#deprecated-sdk-helpers)**);在此之前,提供者会发出警告,并使用发现找到的那个授权服务器。 * `scope` 是空格分隔的字符串,即 OAuth 的线路格式。 * 下游的一切完全相同:同样的 `TokenStorage`、同样的 `httpx2.AsyncClient(auth=...)`、同样的 `streamable_http_client`。 @@ -121,7 +124,7 @@ URL 必须是 HTTPS 且路径不能是根路径;否则在构造时就是 `Valu 从环境变量或密钥管理器读取 `client_secret`,绝不要从源码版本控制里读。 !!! info - `mcp.client.auth.extensions.client_credentials` 里还有一个提供者:**`PrivateKeyJWTOAuthProvider`**,用于以 JWT 而非共享密钥进行认证的客户端(`private_key_jwt`,即密钥对和工作负载身份那一类)。它遵循同样的模式:构造一个,放到 `auth=` 上。同一个模块还附带 `SignedJWTParameters` 和 `static_assertion_provider`,两个用来构建其断言的辅助工具。 + `mcp.client.auth.extensions.client_credentials` 里还有一个提供者:**`PrivateKeyJWTOAuthProvider`**,用于以 JWT 而非共享密钥进行认证的客户端(`private_key_jwt`,即密钥对和工作负载身份那一类)。它遵循同样的模式:构造一个(它接受同样可选的 `issuer`),放到 `auth=` 上。同一个模块还附带 `SignedJWTParameters` 和 `static_assertion_provider`,两个用来构建其断言的辅助工具。 还有一种没有人参与的情形:客户端属于某个企业,由企业的身份提供者而不是用户来决定它可以访问哪些 MCP 服务器。那是另一种授权方式,有自己的信任模型和自己的页面,**[身份断言](identity-assertion.md)**。 diff --git a/i18n/zh/pages/client/session-groups.md b/i18n/zh/pages/client/session-groups.md index 1f68793f..1a5ecb7c 100644 --- a/i18n/zh/pages/client/session-groups.md +++ b/i18n/zh/pages/client/session-groups.md @@ -1,6 +1,6 @@ --- translation: - sections: [09c857a25a9dc37a, 43bc6a76a243a50e, 0a716022a88768df, 4b7f78042bfcfff7, c112662e61b03315, 58974ba1f489a8b4, d18adbdbb835ea73] + sections: [09c857a25a9dc37a, 43bc6a76a243a50e, 0a716022a88768df, 4b7f78042bfcfff7, c112662e61b03315, 58974ba1f489a8b4, ed4d17e894864056] tool: 1 --- # 会话组 {#session-groups} @@ -76,7 +76,7 @@ translation: ## 回顾 {#recap} * `ClientSessionGroup` 持有多条服务器连接,并把它们的工具、资源和提示词各自合并成一个 `dict`。 -* 每个服务器调用一次 `connect_to_server(params)`。它接受传输参数,从不接受 `Client` 所接受的服务器对象或 URL。 +* 每个服务器调用一次 `connect_to_server(params)`。它接受的是传输参数,从不接受 `Client` 所接受的 URL 或 `Transport`。 * `group.call_tool(name, arguments)` 替你路由到拥有该工具的服务器。 * 名称必须在整个组内唯一;两个都有 `search` 工具的服务器无法直接共存。 * `component_name_hook=` 改写每个注册的名称。改变的是 dict 的键,线路上的名称不变。 diff --git a/i18n/zh/pages/client/transports.md b/i18n/zh/pages/client/transports.md index ecfd8ac8..d505e945 100644 --- a/i18n/zh/pages/client/transports.md +++ b/i18n/zh/pages/client/transports.md @@ -1,6 +1,6 @@ --- translation: - sections: [9cac816674181eb0, 0700f337babcd4dd, 2bde0dd58cdf00f5, 40b4916d82eaf1d4, 3d0832f39b0d7059, dfa4446556badef0, 5bd93be2ab2ecb9c] + sections: [9cac816674181eb0, 7c157764133fea1f, 40b4916d82eaf1d4, 10d151f2cc75317f, 3d0832f39b0d7059, 92742ba36533633d, 0aeca6145e7bd302] tool: 1 --- # 客户端传输 {#client-transports} @@ -11,30 +11,15 @@ translation: 每种传输的**服务器**一侧(`mcp.run()` 做什么、你部署什么)见 **[运行你的服务器](../run/index.md)**。 -## 内存中 {#in-memory} - -传入服务器对象本身: - -```python title="client.py" hl_lines="14" ---8<-- "docs_src/client_transports/tutorial001.py" -``` - -没有子进程,没有端口,线路上没有任何字节。客户端和服务器是同一个进程里的两个对象,而调用仍然走真实的协议层:`search_books` 的列出、校验和调用,和走 HTTP 时完全一样。 - -这让它同时具有两种用途: - -* **测试支架。** 本文档中的每个示例都是这样跑通的,**[测试](../get-started/testing.md)** 页面围绕它构建了整套模式。 -* **嵌入 API。** 自己构造服务器的应用不需要经过网络就能调用它的工具。 - ## Streamable HTTP {#streamable-http} -传入一个 URL 字符串,得到的就是 **Streamable HTTP**,也就是部署时用的传输方式: +传入一个 URL 字符串,得到的就是 **Streamable HTTP**:部署时用的传输方式,也是应该首选的那一种: ```python title="client.py" hl_lines="5" --8<-- "docs_src/client_transports/tutorial002.py" ``` -这就是完整的生产环境客户端。`Client` 替你把 URL 包进 `streamable_http_client(...)`,底层是一个按 MCP 的需要配置好的 `httpx2.AsyncClient`:`follow_redirects=True`,connect/write/pool 超时 30 秒,读超时 300 秒,因为服务器可能会一直保持响应流打开。 +这就是完整的生产环境客户端。`Client` 替你把 URL 包进 `streamable_http_client(...)`,底层是一个按 MCP 的需要配置好的 `httpx2.AsyncClient`:connect/write/pool 超时 30 秒,读超时 300 秒,因为服务器可能会一直保持响应流打开。 !!! check 构造出来的 `Client` **并未**连接。构造只是选定传输方式;打开它的是 `async with`。在进入之前就去取连接,SDK 会明确告诉你: @@ -49,7 +34,7 @@ translation: 一旦需要 `Authorization` 头、cookie、代理、mTLS 或不同的超时,就自己构建 `httpx2.AsyncClient`,再把它交给 `streamable_http_client`: -```python title="client.py" hl_lines="8-14" +```python title="client.py" hl_lines="8-13" --8<-- "docs_src/client_transports/tutorial003.py" ``` @@ -58,10 +43,7 @@ translation: * `httpx2.AsyncClient` 归你所有,所以由**你**进入和退出它。SDK 从不关闭不是它自己创建的客户端。 * `streamable_http_client(url, http_client=...)` 返回一个传输,`Client(transport)` 像接受其他任何东西一样接受它。 -关于 TLS 的一点说明:`httpx2` 依据操作系统的信任库(通过 -[`truststore`](https://pypi.org/project/truststore/))校验证书,而不是自带的 CA 列表。在没有可用系统 CA 库的环境(某些精简容器)中,设置标准的 `SSL_CERT_FILE`/`SSL_CERT_DIR` -环境变量,或者给你的 `httpx2.AsyncClient` 显式传入 `verify=ssl_context`(背景见 -[`httpx` 和 `httpx-sse` 被 `httpx2` 取代](../migration.md#httpx-and-httpx-sse-replaced-by-httpx2))。 +关于 TLS 的一点说明:`httpx2` 依据操作系统的信任库(通过 [`truststore`](https://pypi.org/project/truststore/))校验证书,而不是自带的 CA 列表。在没有可用系统 CA 库的环境(某些精简容器)中,设置标准的 `SSL_CERT_FILE`/`SSL_CERT_DIR` 环境变量,或者给你的 `httpx2.AsyncClient` 显式传入 `verify=ssl_context`(背景见 [`httpx` 和 `httpx-sse` 被 `httpx2` 取代](../migration.md#httpx-and-httpx-sse-replaced-by-httpx2))。 !!! warning `streamable_http_client` 过去可以直接接受 `headers=` 和 `timeout=`。现在不行了:它只有 `url`、`http_client` 和 `terminate_on_close` 三个参数。习惯性地去用 `headers=`,会得到: @@ -73,7 +55,25 @@ translation: 所有 HTTP 层面的东西现在都放在你传入的那一个 `httpx2.AsyncClient` 上。 !!! info - `httpx2` 保留了熟悉的 `httpx` API,所以只要会 `httpx`,就已经知道在这里怎么做认证、代理、事件钩子、重试和连接限制。SDK 既不在上面加东西,也不拿走什么。OAuth 也是在这里接入的:`httpx2.AsyncClient(auth=OAuthClientProvider(...))`。整个流程见 **[OAuth 客户端](oauth-clients.md)**。 + `httpx2` 保留了熟悉的 `httpx` API,所以只要会 `httpx`,就已经知道在这里怎么做认证、代理、事件钩子、重试和连接限制。SDK 既不在上面加东西,也不拿走什么,[重定向处理](#redirects)除外。OAuth 也是在这里接入的:`httpx2.AsyncClient(auth=OAuthClientProvider(...))`。整个流程见 **[OAuth 客户端](oauth-clients.md)**。 + +### 重定向 {#redirects} + +传输只连接你给它的那个 URL,而且只连接那个源(origin)。 + +* 停留在同一 scheme、主机和端口上的 `307`/`308` 重定向会被跟随,同一主机上的 `http://` → `https://` 也会。这覆盖了常见的 `/mcp` → `/mcp/` 尾部斜杠重定向。 +* 指向其他任何地方的重定向都**不会**被跟随。调用会失败并报错: + + ```text + MCPError: Redirect to https://other.example.com/mcp not followed; use that URL as the endpoint if it is the intended server + ``` + + 如果那个 URL 就是你要连的服务器,把它写进配置。如果不是,说明服务器或它前面的代理配置有误。 + +这对你传入的任何 `httpx2.AsyncClient` 都成立:MCP 请求不会参考它的 `follow_redirects` 设置,无论设成哪一种。SDK 的 OAuth provider 对它们自己的请求也应用同样的规则。 + +!!! tip + `Redirect to http://… not followed: it would downgrade this HTTPS endpoint to plain HTTP` 的意思是:服务器前面有一个它不知道的 TLS 终止代理,于是它发出的是 `http://` 重定向。这要在服务器端修复(**[部署与扩展](../run/deploy.md#behind-a-tls-terminating-proxy)**),或者直接使用报错信息建议的那个精确的 `https://…/` URL。 ## stdio {#stdio} @@ -94,6 +94,18 @@ translation: 需要 API key 的服务器在那里找不到它。用 `env=` 显式传入;这些变量会合并到允许列表之上。上面的 `BOOKSHOP_API_KEY` 做的就是这件事。 +## 内存中 {#in-memory} + +在测试里没有什么要部署,也没有什么要启动。传入服务器对象本身: + +```python hl_lines="14" +--8<-- "docs_src/client_transports/tutorial001.py" +``` + +没有子进程,没有端口,线路上没有任何字节。客户端和服务器是同一个进程里的两个对象,而调用仍然走真实的协议层:`search_books` 的列出、校验和调用,和走 HTTP 时完全一样。**[测试](../get-started/testing.md)** 页面围绕它构建了整套模式。 + +同样的形式也兼作嵌入 API:自己构造服务器的应用不需要经过网络就能调用它的工具。 + ## SSE {#sse} `sse_client(url)` 来自 `mcp.client.sse`,是被 Streamable HTTP 取代的那个 HTTP 传输。用同样的方式包一层,`Client(sse_client("http://localhost:8000/sse"))`,就能和仍在使用它的服务器通信;不要在它之上构建任何新东西。 @@ -102,15 +114,16 @@ translation: 对 `Client` 来说,上面这些都是同一种东西。 -**传输**是任何能产出一对 `(read, write)` 消息流的异步上下文管理器:正式地说,就是 `mcp.client` 中的 `Transport` 协议。`Client` 按类型解析它的参数:服务器对象在进程内连接,`str` 变成 `streamable_http_client(url)`,`StdioServerParameters` 变成 `stdio_client(params)`,其他任何东西都直接作为传输进入。正是最后这条规则让 `stdio_client(...)`、`streamable_http_client(...)` 和 `sse_client(...)` 都能放进同一个位置,也让你可以自己写一个。 +**传输**是任何能产出一对 `(read, write)` 消息流的异步上下文管理器:正式地说,就是 `mcp.client` 中的 `Transport` 协议。`Client` 按类型解析它的参数:`str` 变成 `streamable_http_client(url)`,`StdioServerParameters` 变成 `stdio_client(params)`,服务器对象在进程内连接,其他任何东西都直接作为传输进入。正是最后这条规则让 `stdio_client(...)`、`streamable_http_client(...)` 和 `sse_client(...)` 都能放进同一个位置,也让你可以自己写一个。 ## 回顾 {#recap} -* `Client(mcp)`(服务器对象)在内存中连接。用于测试和嵌入。 * `Client("http://.../mcp")`(URL)通过 Streamable HTTP 连接,即生产环境的传输方式。 * 请求头、认证、代理和超时应放在 `httpx2.AsyncClient` 上,再传给 `streamable_http_client(url, http_client=...)`。没有 `headers=` 关键字参数。 +* 重定向只在 URL 自己的源之内被跟随(尾部斜杠的 `307`/`308`),外加同一主机上的 `http`→`https`。其他情况都会以 `Redirect to … not followed` 失败;把最终的 URL 写进配置。 * stdio 是 `Client(StdioServerParameters(...))`。只有在需要重定向子进程的 stderr 时,才自己用 `stdio_client(...)` 包一层。 * 子进程拿到的是允许列表里的环境,不是你的环境;`env=` 往里添加。 +* `Client(mcp)`(服务器对象)在内存中连接。用于测试,或者把服务器嵌入构建它的那个应用。 * 传输就是任何可以 `async with x as (read, write)` 的东西。凡不是服务器对象、URL 或 `StdioServerParameters` 的参数,`Client` 都直接交给这个协议。 * 构造 `Client` 选定传输方式。`async with` 打开它。 diff --git a/i18n/zh/pages/deprecated.md b/i18n/zh/pages/deprecated.md index 9785fdf2..7d01c3ef 100644 --- a/i18n/zh/pages/deprecated.md +++ b/i18n/zh/pages/deprecated.md @@ -1,11 +1,11 @@ --- translation: - sections: [490237e61c3a7a44, 01262a123ad9501d, 429db5b574a2ac08, e2d0d273fbd2d74b, 64ab0331e868f3d4, 6c8878ce2d1f6d56, 4068f23e371bf0b3, eaef75b8725bc931] + sections: [4c1dea378b2b1bf7, 01262a123ad9501d, 429db5b574a2ac08, e2d0d273fbd2d74b, 64ab0331e868f3d4, 6c8878ce2d1f6d56, c3d1099701156881, e185a1b8e53669f6] tool: 1 --- # 已弃用的功能 {#deprecated-features} -2026-07-28 规范让五项内容退役。SDK 仍然实现了其中每一项,而且每一项现在都带有**弃用警告**。另有一个 SDK 辅助函数因自身原因被弃用,列在[本页末尾](#deprecated-sdk-helpers)。 +2026-07-28 规范让五项内容退役。SDK 仍然实现了其中每一项,而且每一项现在都带有**弃用警告**。另有几项 SDK 层面的弃用出于自身原因,列在[本页末尾](#deprecated-sdk-helpers)。 下表列出了每一项已弃用的功能、它为什么要退场,以及应该改用的替代方案。 @@ -125,11 +125,13 @@ warnings.filterwarnings("ignore", category=MCPDeprecationWarning) ## 已弃用的 SDK 辅助函数 {#deprecated-sdk-helpers} -这些不是规范变更,只是有了更好替代的 SDK 内部实现。它们用同一个 `MCPDeprecationWarning` 发出警告,并将在 3.0 中移除。 +这些不是规范变更,只是有了更好替代的 SDK 用法。它们用同一个 `MCPDeprecationWarning` 发出警告,3.0 会移除旧形式。 | 已弃用 | 替代做法 | |---|---| | `FuncMetadata.call_fn_with_arg_validation()` | 先调用 `FuncMetadata.validate_arguments()`,再调用 `FuncMetadata.call_fn()`。只有直接驱动 `FuncMetadata` 的代码(比如自定义的 `Tool` 子类)才调用过它。 | +| 不带 `validate_token_resource=` 的 `AuthSettings(resource_server_url=...)` | 设置它:`True` 让服务器拒绝你的验证器未报告为针对 `resource_server_url` 签发的 bearer 令牌,`False` 表示你的验证器自己检查令牌的受众(见 **[授权](run/authorization.md#a-token-verifier)**)。不设置时行为等同于 `False`;3.0 起,只要设置了 `resource_server_url`,默认值就是 `True`。 | +| 不带 `issuer=` 的 `ClientCredentialsOAuthProvider(...)` 或 `PrivateKeyJWTOAuthProvider(...)` | 传入 `issuer=`,指明签发这些凭据的授权服务器(见 **[编写 OAuth 客户端](client/oauth-clients.md#machine-to-machine)**)。不传的话,由 MCP 服务器决定哪个授权服务器收到这些凭据;3.0 起这个关键字参数必填。 | ## 回顾 {#recap} @@ -138,7 +140,7 @@ warnings.filterwarnings("ignore", category=MCPDeprecationWarning) * 弃用只是建议性的:线路上没有变化,在 2026 之前的会话上一切照常工作,你会看到一条醒目的 `MCPDeprecationWarning`(它是 `UserWarning`,所以默认开启)。 * 采样和根目录还需要一条反向通道,而 2026-07-28 会话没有。在现代连接上,它们先警告,然后抛出异常。 * `warnings.filterwarnings("ignore", category=MCPDeprecationWarning)` 让整个类别静音;pytest 中的 `"error::mcp.MCPDeprecationWarning"` 把它变成测试失败。 -* 一个 SDK 辅助函数 `FuncMetadata.call_fn_with_arg_validation()` 单独被弃用,将在 3.0 中移除。 +* [SDK 层面的弃用](#deprecated-sdk-helpers)遵循同样的规则:现在发出警告,3.0 移除旧形式。 * 新代码不应建立在其中任何一项之上。 本文档的其他每一页讲的都是当前的 API。 diff --git a/i18n/zh/pages/get-started/first-steps.md b/i18n/zh/pages/get-started/first-steps.md index a93641a4..7cb46def 100644 --- a/i18n/zh/pages/get-started/first-steps.md +++ b/i18n/zh/pages/get-started/first-steps.md @@ -1,6 +1,6 @@ --- translation: - sections: [0d6c05bcbf836bf3, 59a7b14eeefc68c1, 7114d8d6daba203f, e8bbb56a98ba7bc9, 5138010f6159901c, f78da7c7c363d4c6, 220a939cab348686] + sections: [0d6c05bcbf836bf3, 9a78b5f6b44b18ab, 7114d8d6daba203f, e8bbb56a98ba7bc9, bfd2fd1153e71dac, 1615a994ef071fdd, 65c599fae991f245] tool: 1 --- # 第一步 {#first-steps} @@ -17,7 +17,7 @@ translation: * **客户端** 位于宿主内部,讲 MCP。宿主每连接一个服务器,就运行一个客户端。 * **服务器** 是你用这个 SDK 构建的东西。它向客户端暴露内容,从不直接和模型对话。 -你写的是服务器。宿主是别人的产品。SDK 还提供了一个 `Client`,你会用它来测试自己的服务器,本页后面就会用到。 +你写的是服务器。宿主是别人的产品。SDK 还提供了一个 `Client`,宿主通过 URL 连接服务器或把服务器作为子进程启动时用的就是这个类。本页后面会用到它,测试自己的服务器也靠它。 ## 三种原语 {#the-three-primitives} @@ -79,22 +79,20 @@ Inspector 是通过 **stdio** 运行你的服务器的,这是 MCP 服务器可 客户端连接时,服务器会声明自己的 **能力**:它会响应哪几类请求。客户端根据这份声明来决定该请求什么。这份声明你从没写过;是 `MCPServer` 替你声明的。 -自己看一下。SDK 的 `Client` 可以直接接受服务器对象,并在 **内存中** 与之连接(没有子进程,没有端口): +自己看一下。在一个终端里让 `server.py` 通过 HTTP 运行着: -```python -import asyncio +```console +uv run mcp run server.py --transport streamable-http +``` -from mcp import Client +然后在另一个终端里用客户端连上它: -from server import mcp +```python title="client.py" hl_lines="7-8" +--8<-- "docs_src/first_steps/tutorial001_client.py" +``` - -async def main() -> None: - async with Client(mcp) as client: - print(client.server_capabilities.model_dump(exclude_none=True)) - - -asyncio.run(main()) +```console +python client.py ``` ```text @@ -114,7 +112,7 @@ asyncio.run(main()) 注意这里缺了什么。`completions`(资源模板和提示词的参数自动补全)需要一个由你编写的处理函数,而这个服务器没有,所以这项能力不会出现,行为规范的客户端也就不会去问。所有可选项都遵循这条规则:注册了对应的东西,能力就出现;**[补全](../servers/completions.md)** 会证明这一点。 !!! info - `Client(mcp)` 正是这些文档里每个示例测试时所用的那个内存客户端,你测试自己的服务器也会用它。它有整整一页:**[测试](testing.md)**。 + 这个 `client.py` 就是一个完整的 MCP 客户端,**[客户端](../client/index.md)** 是它的专页。测试时可以省掉终端和端口,把服务器对象本身交给 `Client`,即 `Client(mcp)`。它也有整整一页:**[测试](testing.md)**。 ## 你没有写的东西 {#what-you-did-not-write} @@ -123,7 +121,7 @@ asyncio.run(main()) * JSON Schema。`a: int, b: int` **就是** `add` 的模式。 * 请求处理函数。`tools/list`、`resources/read`、`prompts/get`:全都替你处理好了。 * 能力声明。`MCPServer` 替你生成了。 -* 一行协议代码。版本协商、JSON-RPC 分帧、能力交换:全都发生在 `mcp dev` 和 `Client(mcp)` 内部,你一眼都没见到。 +* 一行协议代码。版本协商、JSON-RPC 分帧、能力交换:全都发生在 `mcp dev` 和 `client.py` 内部,你一眼都没见到。 这个比例,正是这个 SDK 的意义所在。 @@ -134,6 +132,6 @@ asyncio.run(main()) * 每种原语一个装饰器:`@mcp.tool()`、`@mcp.resource(uri)`、`@mcp.prompt()`。名称、描述和模式都来自函数本身。 * 带 `{param}` 的 URI 生成的是资源 **模板**,与具体资源分开列出。 * 服务器的 **能力** 会替你声明好,而客户端只会请求服务器声明过的内容。 -* `Client(mcp)` 在内存中连接服务器对象:从第一天起,它就是你的测试工具。 +* `Client("http://localhost:8000/mcp")` 与正在运行的服务器对话。改为把服务器对象交给它,即 `Client(mcp)`,它从第一天起就是你的测试工具。 接下来是 **[连接到真实宿主](real-host.md)**:把这个服务器真正放进 Claude Desktop 或 IDE 里。然后是 **[测试](testing.md)**:一页内容,一个内存客户端,从此不用再猜它到底能不能用。再之后,每种原语各有自己的一页,从模型驱动的那一种开始:**[工具](../servers/tools.md)**。 diff --git a/i18n/zh/pages/get-started/testing.md b/i18n/zh/pages/get-started/testing.md index 234d378b..6e839044 100644 --- a/i18n/zh/pages/get-started/testing.md +++ b/i18n/zh/pages/get-started/testing.md @@ -1,13 +1,13 @@ --- translation: - sections: ['4926721070127497', c52a1de2b6b32f40, 8e792bf8c7489ec6, 627195f7159e24ef] + sections: [5d13c2f0ba42c0d2, c52a1de2b6b32f40, 8e792bf8c7489ec6, 38552ea228b0a04f] tool: 1 --- # 测试 {#testing} -Python SDK 提供了一个带**内存传输**的 `Client` 类:把服务器对象传给它,它就会直接连接上去。 +SDK 的 `Client` 类,也就是连接 URL 或启动子进程的那个类,还能在**内存中**连接:把服务器对象传给它,它就直接和服务器对话。 -不用子进程,不占端口,根本不走任何传输。思路和 FastAPI 的 `TestClient` 一样。 +不用子进程,不占端口,线路上什么都没有。思路和 FastAPI 的 `TestClient` 一样。 ## 基本用法 {#basic-usage} @@ -86,7 +86,7 @@ async def test_call_add_tool(client: Client): 测试里就让它开着。它在生产代码中没有意义。 -## 默认在进程内 {#in-process-by-default} +## 默认不区分协议时代 {#era-neutral-by-default} !!! note `Client(mcp)` 在进程内连接,默认**不区分协议时代**:它会先探测服务器,再选择合适的协议路径。如果测试要验证旧版(legacy)特有的语义(采样(sampling)或征询(elicitation)的推送、`message_handler`),就固定使用 `mode="legacy"`,并在这种情况下去掉 `raise_exceptions=True`:旧版连接本来就不做脱敏,而这个标志会让失败在服务器任务内部重新抛出,而不是抛到你的测试里。 diff --git a/i18n/zh/pages/handlers/multi-round-trip.md b/i18n/zh/pages/handlers/multi-round-trip.md index 2c5b0e7f..8f64990d 100644 --- a/i18n/zh/pages/handlers/multi-round-trip.md +++ b/i18n/zh/pages/handlers/multi-round-trip.md @@ -1,6 +1,6 @@ --- translation: - sections: [74011e683045eea9, 9b64cc175c18b6a9, 4b41be4824030397, e3b1502da786ec33, 71e41161f143c6a9, 9ec2c1eeb8c36378, 8dd027377d46448b, f81491125dcbfe8b] + sections: [74011e683045eea9, 9b64cc175c18b6a9, 4b41be4824030397, e3b1502da786ec33, 71e41161f143c6a9, 9ec2c1eeb8c36378, b47667184ca5b516, f81491125dcbfe8b] tool: 1 --- # 多轮往返(multi-round-trip)请求 {#multi-round-trip-requests} @@ -162,7 +162,7 @@ TTL、主体绑定和请求绑定**不是** codec 的工作:对每个 codec, ## 一个 2026-07-28 的结果 {#a-2026-07-28-result} -`InputRequiredResult` 只存在于协议版本 **2026-07-28**。内存中的 `Client(server)` 替你协商它;走线路时,`mode="auto"` 会发现它。连接之后,`client.protocol_version` 告诉你拿到的是什么。 +`InputRequiredResult` 只存在于协议版本 **2026-07-28**。`Client` 默认的 `mode="auto"` 在任何连接上都会发现它。连接之后,`client.protocol_version` 告诉你拿到的是什么。 !!! warning 2026 之前的会话没有地方放 `InputRequiredResult`。在 `mode="legacy"` 连接上从处理函数返回一个,运行器无法把它序列化到协商好的版本;客户端收到的是 `-32603`“Handler returned an invalid result”错误。同时服务两个时代的服务器在用它之前必须检查 `ctx.protocol_version`。 diff --git a/i18n/zh/pages/handlers/progress.md b/i18n/zh/pages/handlers/progress.md index a39d3be2..1461f326 100644 --- a/i18n/zh/pages/handlers/progress.md +++ b/i18n/zh/pages/handlers/progress.md @@ -1,6 +1,6 @@ --- translation: - sections: [5315262fe26b33e1, 9d8e98840f1b78f0, 0284b215e85366c4, 8534d8dbb4053a70, 2966fac6fe697007] + sections: [5315262fe26b33e1, 9d8e98840f1b78f0, 52d6009a07e770ea, 8534d8dbb4053a70, 2966fac6fe697007] tool: 1 --- # 进度 {#progress} @@ -29,19 +29,17 @@ translation: 客户端**按调用**选择接收,方法是给 `call_tool` 传 `progress_callback=`: -```python title="client.py" hl_lines="7 16" +```python title="client.py" hl_lines="5 14" import anyio from mcp import Client -from server import mcp - async def show(progress: float, total: float | None, message: str | None) -> None: print(f"{message} ({progress}/{total})") async def main() -> None: - async with Client(mcp) as client: + async with Client("http://localhost:8000/mcp") as client: result = await client.call_tool( "import_catalog", {"urls": ["https://example.com/a.json", "https://example.com/b.json"]}, @@ -56,23 +54,27 @@ anyio.run(main) 回调是一个 `async` 函数,接收的正是服务器报告的内容:`progress`、`total`、`message`。 !!! info - `Client(mcp)` 直接在内存中连接到服务器对象,和 **[测试](../get-started/testing.md)** 页面所用的是同一个客户端。无论 `Client` 用哪种传输方式,`progress_callback` 都是同一个参数;接下来看到的**时序**则是内存连接特有的。它以内联方式运行你的回调,所以每条报告都在 `call_tool` 返回之前送达。换成真实的传输方式,通知会和结果竞速,`call_tool` 已经返回之后,一个慢的回调可能还在运行。 + 无论交给 `Client` 的是什么——像这里这样的 URL、`StdioServerParameters`,还是测试里的服务器对象——`progress_callback` 都是同一个参数。不过,走真实传输方式时要留意时序。每条通知都是单独送达的,和响应各走各的,所以 `call_tool` 已经返回之后,一个慢的回调可能还在运行。只有进程内的测试连接会以内联方式运行回调,并保证每条报告都先送达。 ### 试一试 {#try-it} -把 `client.py` 放在 `server.py` 旁边,然后运行: +通过 HTTP 启动 `server.py`,然后在另一个终端运行客户端: + +```console +uv run mcp run server.py --transport streamable-http +``` ```console python client.py ``` ```text -Imported https://example.com/a.json (1/2) -Imported https://example.com/b.json (2/2) +Imported https://example.com/a.json (1.0/2.0) +Imported https://example.com/b.json (2.0/2.0) {'result': 'Imported 2 records.'} ``` -服务器上的每一次 `await ctx.report_progress(...)` 都变成了客户端上对 `show` 的一次调用,顺序不变,而且两行都在 `call_tool` 返回**之前**打印了出来。进度不会打包进结果里;它在工具还在干活的时候就流式送出。 +服务器上的每一次 `await ctx.report_progress(...)` 都变成了客户端上对 `show` 的一次调用,顺序不变。进度不会打包进结果里,而是在工具还在干活的时候就流式送出。 !!! warning `progress_callback` 属于**调用**,而不是 `Client`。没有对应的构造函数参数,因为不同的调用想要不同的回调:这一次驱动下载进度条,下一次是一行日志。 diff --git a/i18n/zh/pages/protocol-versions.md b/i18n/zh/pages/protocol-versions.md index c2be6477..8f9a128c 100644 --- a/i18n/zh/pages/protocol-versions.md +++ b/i18n/zh/pages/protocol-versions.md @@ -1,6 +1,6 @@ --- translation: - sections: [478fd619e5f90ef8, aef094a00e44e248, bab8cbf3449fa7e9, df1809b15a58335b, 5f9d8c2336ed0239, f54974398e43ddef, b24443dd78584870] + sections: [d98e337bf28845e0, dd642acbba36f615, f547b3e76da19c94, 149513378588da5c, 4e149ed992046709, f54974398e43ddef, b24443dd78584870] tool: 1 --- # 协议版本 {#protocol-versions} @@ -11,9 +11,17 @@ MCP 有两个时代。 你几乎不需要关心这些,因为 `Client` 会替你协商。本页讲的是控制这一行为的唯一一个构造参数 `mode=`,以及需要改动它的三种情形。 +本页的每段代码都是一个 `client.py`,连接的是 **[客户端](client/index.md)** 里那个 Bookshop 的 `server.py`。先在一个终端里启动那个服务器: + +```console +uv run mcp run server.py --transport streamable-http +``` + +然后在另一个终端里用 `python client.py` 运行每段代码。 + ## `mode="auto"` {#modeauto} -```python title="client.py" hl_lines="14-15" +```python title="client.py" hl_lines="7-8" --8<-- "docs_src/protocol_versions/tutorial001.py" ``` @@ -31,11 +39,11 @@ MCP 有两个时代。 整个功能就这些。一个 `Client`,任意时代的服务器,代码里不需要分支。 !!! info - `MCPServer` 在每种传输方式上都会回答 `server/discover`——内存、stdio、Streamable HTTP——所以连接你自己的服务器时,`auto` 总是落在 `2026-07-28`。回退只会在面对真正的 2026 年之前的服务器时触发,而那正是你需要它的时候。 + `MCPServer` 在每种传输方式上都会回答 `server/discover`——Streamable HTTP、stdio,以及测试用的进程内连接——所以连接你自己的服务器时,`auto` 总是落在 `2026-07-28`。回退只会在面对真正的 2026 年之前的服务器时触发,而那正是你需要它的时候。 ## `mode="legacy"` {#modelegacy} -```python title="client.py" hl_lines="14" +```python title="client.py" hl_lines="7" --8<-- "docs_src/protocol_versions/tutorial002.py" ``` @@ -59,7 +67,7 @@ MCP 有两个时代。 `mode` 也接受一个新版协议版本字符串。目前这个集合正好是 `["2026-07-28"]`。 -```python title="client.py" hl_lines="14" +```python title="client.py" hl_lines="7" --8<-- "docs_src/protocol_versions/tutorial003.py" ``` @@ -90,7 +98,7 @@ ValueError: mode must be 'legacy', 'auto', or one of ['2026-07-28']; got '2025-0 所以把它存下来。一次 `auto` 连接之后,`client.session.discover_result` 保存着服务器发来的那个 `DiscoverResult` 原样:它的 `supported_versions`、它的 `capabilities`、它的 `instructions`,以及服务器写进结果 `_meta` 里的身份信息。下次把它作为 `prior_discover=` 传回去: -```python title="client.py" hl_lines="15 17" +```python title="client.py" hl_lines="8 10" --8<-- "docs_src/protocol_versions/tutorial004.py" ``` diff --git a/i18n/zh/pages/run/asgi.md b/i18n/zh/pages/run/asgi.md index 40d01513..a8331504 100644 --- a/i18n/zh/pages/run/asgi.md +++ b/i18n/zh/pages/run/asgi.md @@ -1,6 +1,6 @@ --- translation: - sections: [1062ef792791488a, 4be2b831547184a9, 374b049e770385f2, b72f6947089e6de0, b172c9db7831bb31, 70b9ece244ca1b0c, cba78e052898c3f6, f06bdb541cb0b469, fb82d526320b7cc3] + sections: [1062ef792791488a, 4be2b831547184a9, 374b049e770385f2, b72f6947089e6de0, b172c9db7831bb31, 10394c6f16601638, cba78e052898c3f6, f06bdb541cb0b469, 6dc898ccc5a903f9] tool: 1 --- # 添加到现有应用 {#add-to-an-existing-app} @@ -86,7 +86,7 @@ Starlette 的 `Host` 路由用法相同:把 `Mount("/", ...)` 换成 `Host("mc --8<-- "docs_src/asgi/tutorial004.py" ``` -现在客户端连接 `/notes`,而不是 `/notes/mcp`。 +现在客户端连接 `/notes/`,而不是 `/notes/mcp`。 ## 面向浏览器客户端的 CORS {#cors-for-browser-clients} @@ -127,4 +127,4 @@ Starlette 的 `Host` 路由用法相同:把 `Mount("/", ...)` 换成 `Host("mc * 浏览器客户端需要 CORS:`allow_headers` 放行 `Mcp-*` 请求头,`expose_headers=["Mcp-Session-Id"]` 公开响应头。 * `@mcp.custom_route()` 在 `/mcp` 旁边添加普通的、不做认证的 HTTP 端点。 -服务器一旦能通过真实 URL 访问,**[客户端](../client/index.md)** 就可以用这个 URL 而不是服务器对象来连接它。 +服务器一旦能通过真实 URL 访问,**[客户端](../client/index.md)** 就用这个 URL 来连接它。 diff --git a/i18n/zh/pages/run/authorization.md b/i18n/zh/pages/run/authorization.md index 5b7de60d..f3d2a3de 100644 --- a/i18n/zh/pages/run/authorization.md +++ b/i18n/zh/pages/run/authorization.md @@ -1,6 +1,6 @@ --- translation: - sections: [d62c13457fc4a534, 80e73abaca6e0652, d1dc4c54cd00ec9c, 14ad3bc7904036bb, 5225f127bc1b9c77, fe1626fdd5aad1da, 4556cb7ea1a04a31] + sections: [d62c13457fc4a534, 80e73abaca6e0652, 128a492d18295f64, 14ad3bc7904036bb, 54a6697833fcc1d9, fe1626fdd5aad1da, 811d083c1da8bcf6] tool: 1 --- # 授权 {#authorization} @@ -23,12 +23,12 @@ translation: 有效的 token 长什么样,SDK 没有任何预设。这由你来决定,方式是实现 **`TokenVerifier`**: -```python title="server.py" hl_lines="12-14 19-24" +```python title="server.py" hl_lines="14-16 21-27" --8<-- "docs_src/authorization/tutorial001.py" ``` * `TokenVerifier` 是一个只有一个异步方法的协议。`verify_token` 接收 `Authorization` 头里的原始 token,有效时返回一个 **`AccessToken`**,无效时返回 `None`。除此之外没有别的要实现。 -* 这个例子是在一张表里查找 token。真实的实现会验证 JWT 签名,或者调用授权服务器的 token 自省端点。那部分代码是你的,SDK 只负责调用它。 +* 这个例子是在一张表里查找 token,表中每一项都记录了它是为哪个资源签发的。真实的实现会验证 JWT 签名,或者调用授权服务器的 token 自省端点,并在 `AccessToken.resource` 里报告 token 是签发给谁的(它的 `aud`)。那部分代码是你的,SDK 只负责调用它。 * `token_verifier=` 和 `auth=` 永远成对出现。只传其中一个,`MCPServer(...)` 会在处理任何请求之前就抛出 `ValueError`。 `AuthSettings` 是你的资源服务器对外的门面: @@ -36,6 +36,10 @@ translation: * `issuer_url`:签发你的 token 的授权服务器。 * `resource_server_url`:这个 MCP 端点的公开 URL。它指明 token 是针对**哪一个**资源的,发现文档也位于这里。 * `required_scopes`:每个 token 都必须携带其中全部 scope。 +* `validate_token_resource`:拒绝任何 `AccessToken.resource` 不等于 `resource_server_url` 的 token。设置了 `resource_server_url` 却不设置它,会发出警告(`MCPDeprecationWarning`),行为等同于 `False`;3.0 起资源服务器的默认值会变成 `True`。 + * 如果你的授权服务器把 token 绑定到客户端请求的 `resource`(MCP 客户端总是会发送它),就打开它。让 `resource_server_url` 与客户端连接的 URL 完全一致。 + * 如果你的授权服务器使用自己的受众标识符(Auth0 的 API 标识符、Entra 的应用 ID),就保持关闭,改为在验证器里检查 `aud`,对不属于本服务器的 token 返回 `None`。 + * 如果 `aud` 是列表,把其中等于 `resource_server_url` 的那一项放进 `resource`。 !!! tip "提示" SDK 仓库中的 `examples/servers/simple-auth/` 有一个 `IntrospectionTokenVerifier`,它会调用真实授权服务器的 [RFC 7662](https://datatracker.ietf.org/doc/html/rfc7662) 端点。大多数生产环境的验证器都是这个样子。 @@ -85,7 +89,7 @@ translation: 在任何处理函数内部,**`get_access_token()`** 就是你的验证器为当前请求返回的那个 `AccessToken`: -```python title="server.py" hl_lines="4 32-35" +```python title="server.py" hl_lines="4 35-38" --8<-- "docs_src/authorization/tutorial002.py" ``` @@ -117,6 +121,6 @@ SDK 给你的是资源服务器这一半:验证、公布、拒绝。它不提 * `token_verifier=` 和 `auth=AuthSettings(issuer_url=..., resource_server_url=..., required_scopes=[...])` 永远成对出现。 * SDK 在 `/.well-known/oauth-protected-resource/...` 发布 [RFC 9728](https://datatracker.ietf.org/doc/html/rfc9728) Protected Resource Metadata,并对未认证的请求回应 401,其 `WWW-Authenticate` 头指向该文档。整个发现过程就是这样。 * 在任何处理函数里,`get_access_token()` 就是调用者是谁。 -* 授权是 HTTP 层面的事。`stdio` 和内存内客户端永远看不到它。 +* 授权是 HTTP 层面的事。`stdio` 和内存内测试客户端永远看不到它。 客户端那一半(发现你的授权服务器并替你获取 token)见 **[OAuth 客户端](../client/oauth-clients.md)**。而一个**断言**身份、而不是向用户索要身份的客户端,见 **[身份断言](../client/identity-assertion.md)**。 diff --git a/i18n/zh/pages/run/deploy.md b/i18n/zh/pages/run/deploy.md index fedd4a5d..d6dc95d0 100644 --- a/i18n/zh/pages/run/deploy.md +++ b/i18n/zh/pages/run/deploy.md @@ -1,6 +1,6 @@ --- translation: - sections: [28221886b198784f, f88ea1f1614f3a1d, ce926d686730b6d0, 3be24f8ad8bb5ab9, 3fad24032b2224ff, f25a7f860e579ecb, e758745df6fb7b0a] + sections: [28221886b198784f, f88ea1f1614f3a1d, 2e76f5cb9df15042, ce926d686730b6d0, 3be24f8ad8bb5ab9, 3fad24032b2224ff, f25a7f860e579ecb, 697b01d95080880d] tool: 1 --- # 部署与扩展 {#deploy-scale} @@ -42,6 +42,22 @@ translation: 在客户端那边你找不到这几个字。`421` 是纯文本的 HTTP 响应,不是 JSON-RPC 错误,所以 MCP 客户端抛出的是一个泛泛的传输错误;它不认可的那个主机名只出现在**服务器**的日志里,是一条警告。一个刚部署好、拒绝所有连接的服务器,在证明是别的原因之前,就是 Host 白名单的问题。**[故障排查](../troubleshooting.md)** 也从这里讲起。 +## 在终止 TLS 的代理后面 {#behind-a-tls-terminating-proxy} + +如果 TLS 在代理处终止(ingress、负载均衡器、Caddy、nginx),uvicorn 在它后面提供明文 HTTP,就要让 uvicorn 信任代理的 `X-Forwarded-*` 头: + +```console +uvicorn server:app --proxy-headers --forwarded-allow-ips='' +``` + +不这样做,应用会以为自己是通过 `http://` 提供服务的,它发出的任何重定向(最常见的是 `/mcp` → `/mcp/`)都指向 `http://…`。Python 客户端拒绝从 HTTPS 端点跟随到明文 HTTP,并明确报错: + +```text +MCPError: Redirect to http://mcp.example.com/mcp/ not followed: it would downgrade this HTTPS endpoint to plain HTTP. +``` + +客户端一侧的权宜之计是配置服务器实际提供的确切 URL(`https://mcp.example.com/mcp/`,包含结尾斜杠),这样就不会发生重定向。真正的修复是上面的标志。`FORWARDED_ALLOW_IPS` 是它的环境变量写法;`*` 信任每一跳,只有在除了代理之外没有别的东西能访问到 uvicorn 时才合适。 + ## Worker,以及谁需要粘性 {#workers-and-who-has-to-be-sticky} 主机名能响应之后,就在后面放不止一个 worker。SDK 没有这方面的开关;扩展一个 Starlette 应用和扩展任何 ASGI 应用一样,把对象交给一个会 fork 的东西: @@ -154,6 +170,7 @@ python -c "import secrets; print(secrets.token_hex(32))" ## 回顾 {#recap} * 默认情况下,这个应用只响应发往 localhost 的请求。`transport_security=TransportSecuritySettings(allowed_hosts=[...], allowed_origins=[...])` 是上线的关卡:在你传入它之前,真实主机名后面的每个请求都是 `421`,原因只在服务器日志里。 +* 在终止 TLS 的代理后面,用 `--proxy-headers --forwarded-allow-ips=...` 运行 uvicorn,否则它的重定向指向 `http://`,客户端会拒绝跟随。 * 在 2026-07-28 上没有会话,负载均衡器没有什么可粘的。`stateless_http=True` 是只对旧版有效的开关,因为现代请求在读到这个标志之前就已经被路由并响应了。 * 默认的 `requestState` 密钥是 `os.urandom(32)`,按进程铸造。到达另一个 worker 的多轮往返重试会以 `-32602` “Invalid or expired requestState” 失败。 * 修复办法是 `RequestStateSecurity(keys=[...])` **并且**每个实例使用相同的服务器名字。名字是令牌默认的 audience 声明。相同的密钥,相同的名字。 diff --git a/i18n/zh/pages/run/index.md b/i18n/zh/pages/run/index.md index fb8425f5..b2914c11 100644 --- a/i18n/zh/pages/run/index.md +++ b/i18n/zh/pages/run/index.md @@ -1,6 +1,6 @@ --- translation: - sections: [fea8d769ff9edeba, ce8e2ad42f29ef71, 0d705efb19cf99c2, 2fd7cf825e6d2b2c, 9adc400e8c88e854, 318893ad8e2e9924, 6b63ab96b34476c0] + sections: [fea8d769ff9edeba, ce8e2ad42f29ef71, 0d705efb19cf99c2, 9fd2357154a5b7e7, 9adc400e8c88e854, 318893ad8e2e9924, 6b63ab96b34476c0] tool: 1 --- # 运行服务器 {#running-your-server} @@ -71,6 +71,8 @@ Inspector 做的事和真实宿主完全一样:它把 `server.py` 作为子进 * `json_response=True`:用单个 JSON 正文回应每个 POST,而不是 SSE 流。这个正文只容得下响应本身,别的什么都放不下,所以在请求中途回调客户端的工具(`ctx.elicit()`、采样(sampling))会在这一段抛出 `NoBackChannelError`;与进行中的调用绑定的通知(`ctx.report_progress()` 的进度、每次调用的日志消息)会被丢弃;独立的 `GET` 流仍然承载与之无关的通知。 * `stateless_http=True`:每个请求一个全新的传输,不跟踪会话。 * `max_request_body_size`:接受的最大请求正文大小,单位为字节。默认 4 MiB;更大的请求在解析或创建会话之前就会收到 HTTP 413。只有当合法的 MCP 消息确实超过这个大小时才调高它。 +* `session_idle_timeout`:旧版会话在没有任何进行中请求的情况下可以闲置多少秒,之后服务器会关闭它。默认 1800。`None` 表示禁用。见[会话生存期与限制](legacy-clients.md#session-lifetime-and-limits)。 +* `max_sessions`:一个进程同时持有多少个旧版会话。默认 10 000。`None` 表示不设上限。同一节里有介绍。 * `event_store`、`retry_interval`、`transport_security`:可恢复性和 DNS 重绑定防护。它们可以先放一放,等部署到 localhost 以外的地方再说;**[部署与扩展](deploy.md)** 介绍了 `transport_security`。 !!! warning diff --git a/i18n/zh/pages/run/legacy-clients.md b/i18n/zh/pages/run/legacy-clients.md index 2fe246dc..ce821f75 100644 --- a/i18n/zh/pages/run/legacy-clients.md +++ b/i18n/zh/pages/run/legacy-clients.md @@ -1,6 +1,6 @@ --- translation: - sections: [3d1663c18edc824c, d4fd37009a13f03d, af9f398a5a8b679a, 470c2dd144294d69, 8e45827e6d24e8c8, 91dfd0ce98ebb03c] + sections: [3d1663c18edc824c, 90956965ae6a1ca1, af9f398a5a8b679a, 5ce83b1f9d88da62, 0d9b5d13fffc94e5, 8e45827e6d24e8c8, 91dfd0ce98ebb03c] tool: 1 --- # 服务旧版客户端 {#serving-legacy-clients} @@ -18,15 +18,25 @@ SDK 按 `MCP-Protocol-Version` 头路由每个请求。声明 `2026-07-28` 的 ## 一个处理函数,两个时代 {#one-handler-both-eras} -下面是一个需要向用户提问的工具,以及两个时代的客户端分别调用它: +下面是一个需要向用户提问的工具: -```python title="server.py" hl_lines="24 37-38" +```python title="server.py" hl_lines="21" --8<-- "docs_src/legacy_clients/tutorial001.py" ``` `reserve` 需要一样模型没有提供的东西:要几本。工具用 `Annotated[..., Resolve(ask_quantity)]` 来声明这一点(详见 **[依赖](../handlers/dependencies.md)**)。`reserve` 里没有任何地方提到版本、检查能力或做分支。 -两个客户端**同时**打开,连的是同一个 `mcp` 对象。`mode="legacy"` 会执行 `initialize` 握手:这正是 2026 之前的客户端打开的那种连接。另一个取默认值,落在 `2026-07-28` 上。 +通过 HTTP 提供服务,下面是两个时代的客户端分别调用它: + +```console +uv run mcp run server.py --transport streamable-http +``` + +```python title="client.py" hl_lines="14-15" +--8<-- "docs_src/legacy_clients/tutorial001_client.py" +``` + +两个客户端**同时**打开,连的是同一个正在运行的服务器。`mode="legacy"` 会执行 `initialize` 握手:这正是 2026 之前的客户端打开的那种连接。另一个取默认值,落在 `2026-07-28` 上。在第二个终端运行 `python client.py`: ```text 2025-11-25 {'result': "Reserved 2 of 'Dune'."} @@ -53,6 +63,31 @@ SDK 按 `MCP-Protocol-Version` 头路由每个请求。声明 `2026-07-28` 的 !!! warning `event_store=` 看起来像是解决办法,其实不是。它是**可恢复性**(向重连到**同一个**会话的客户端重放错过的 SSE 事件),不是会话存储。它永远不会让一个会话能从另一个进程访问到。 +## 会话生存期与上限 {#session-lifetime-and-limits} + +旧版会话不会永远存活,一个进程也不会持有无限多个会话。有两个设置控制这一点。两者都是 `run()`、`streamable_http_app()` 和 `Server.streamable_http_app()` 上的关键字参数。现代(`2026-07-28`)连接和 `stateless_http=True` 没有会话,所以这两个设置对它们都不适用。 + +| 设置 | 默认值 | 作用 | 客户端看到什么 | 关闭方式 | +|---|---|---|---|---| +| `session_idle_timeout` | `1800`(30 分钟) | 关闭在这么长时间里没有任何进行中事务的会话。 | `404 Session not found`。它必须重新 `initialize`。 | `None` | +| `max_sessions` | `10_000` | 超过这个数量就拒绝再打开会话。现有会话不受影响,也不会驱逐任何会话。 | `503 Too many open sessions`,JSON-RPC 代码为 `-32603`。 | `None` | + +什么算“进行中”: + +* 一个打开的 `GET` 流。SDK 客户端会保持一个打开,所以已连接客户端的会话永远不会过期。 +* 一个仍在应答中的请求。运行时间超过超时的工具调用不会被中断,倒计时在它完成之后才开始。 +* 没有别的了。请求之间时钟照走。会话上的任何请求都会重置它,`ping` 也算。会话一旦过期,什么都救不回来。 + +用 `DELETE` 结束会话的客户端会立即释放它。开场请求被拒绝的客户端也是如此。 + +```python +mcp.run(transport="streamable-http", session_idle_timeout=None, max_sessions=50_000) +``` + +两种事件都会出现在服务器日志里。过期是 `INFO` 级别的 `Session idle timeout`。拒绝打开是 `WARNING` 级别的 `Refusing to open a new session: sessions are already open`。 + +这些上限按进程计。有四个 worker 时上限是 `max_sessions` 的四倍,每个 worker 各自让自己的会话过期。 + ## 唯一的开关:`stateless_http` {#the-one-knob-stateless_http} 如果粘性是你不愿付的代价,那么恰好有一样东西可以改。 @@ -73,7 +108,7 @@ SDK 按 `MCP-Protocol-Version` 头路由每个请求。声明 `2026-07-28` 的 `json_response=True` 不是那个开关,但它在**每一个**旧版会话上都要付一半同样的代价:用一个 JSON 正文回答的 `POST` 没有供请求范围通道使用的流,所以请求中途的 `ctx.elicit()` 会抛出同样的 `NoBackChannelError`,与该请求绑定的通知会被丢弃。会话的独立流不受影响:无关的通知仍然能到达。 !!! check - 故意做错一次。`reserve` 就是刚才同时服务两个客户端的那个工具。用 `stateless_http=True` 部署它,通过 HTTP 连上同样的两个客户端,分别调用它。 + 故意做错一次。`reserve` 就是刚才同时服务两个客户端的那个工具。用 `stateless_http=True` 部署它,连上同样的两个客户端,分别调用它。 现代客户端仍然收到 `Reserved 2 of 'Dune'.`,现代这一路没变。 diff --git a/i18n/zh/pages/troubleshooting.md b/i18n/zh/pages/troubleshooting.md index ca4cfc78..ee6f7a6b 100644 --- a/i18n/zh/pages/troubleshooting.md +++ b/i18n/zh/pages/troubleshooting.md @@ -1,6 +1,6 @@ --- translation: - sections: [2efaecdef109a5c5, fcacd3e66b8635a4, 25323d737dcf0261, 8a6e351ec756904d, 137454d469c867f5, 6392596bd6df54f0, 41126fa9c4fe432f, 480b6d7897e30ab4, d83bb682e708dde0, ebbed3449c499db4, 323ef84f6b4bebde, 30fd31be74169d9a, 656943c6cb567218, c2dc3b1007d2e987, 7cf5386b997d04e9, 0b59feed8384456e, 0cba47bae78d04eb, e4355f4c7cf4fb2e] + sections: [3d58228e81b99543, 170514ce901c4139, 17d61fad0a50d62b, 8a6e351ec756904d, 137454d469c867f5, 6392596bd6df54f0, 41126fa9c4fe432f, 480b6d7897e30ab4, d83bb682e708dde0, ebbed3449c499db4, 525cdf1755e29d4c, 30fd31be74169d9a, d2e88333d4f7841f, c2dc3b1007d2e987, d6eabf60cc366341, f798e815252852c2, 0cba47bae78d04eb, 2c218ba829abf74e] tool: 1 --- # 故障排查 {#troubleshooting} @@ -13,6 +13,12 @@ translation: --8<-- "docs_src/troubleshooting/tutorial001.py" ``` +这些条目通过 `http://localhost:8000/mcp` 访问它,所以让它以 HTTP 方式保持运行: + +```console +uv run mcp run server.py --transport streamable-http +``` + 本页引用的错误都是真实的:SDK 自己的测试套件复现了其中每一条。 ## `ExceptionGroup: unhandled errors in a TaskGroup (1 sub-exception)` {#exceptiongroup-unhandled-errors-in-a-taskgroup-1-sub-exception} @@ -23,7 +29,7 @@ translation: ```python async def main() -> None: - async with Client(mcp) as client: + async with Client("http://localhost:8000/mcp") as client: await client.read_resource("weather://Atlantis") ``` @@ -49,7 +55,7 @@ async def main() -> None: ```python async def main() -> None: - async with Client(mcp) as client: + async with Client("http://localhost:8000/mcp") as client: try: await client.read_resource("weather://Atlantis") except MCPError as e: @@ -65,7 +71,7 @@ async def main() -> None: ```python async def main() -> None: - client = Client(mcp) + client = Client("http://localhost:8000/mcp") tools = await client.list_tools() # RuntimeError ``` @@ -73,7 +79,7 @@ async def main() -> None: ```python async def main() -> None: - async with Client(mcp) as client: + async with Client("http://localhost:8000/mcp") as client: tools = await client.list_tools() ``` @@ -245,7 +251,7 @@ app = Starlette(routes=[Mount("/", app=mcp.streamable_http_app())], lifespan=lif ## `MCPError: Session not found` {#mcperror-session-not-found} -服务器不认识客户端发来的 `Mcp-Session-Id`,几乎总是因为服务器**重启了**(或者你被路由到了另一个实例)。会话存活在那一个进程的内存里。 +服务器不认识客户端发来的 `Mcp-Session-Id`。要么服务器**重启了**(或者你被路由到了另一个实例),要么会话**过期了**——在 `session_idle_timeout`(默认 30 分钟)内没有任何进行中的请求。见[会话生存期与限制](run/legacy-clients.md#session-lifetime-and-limits)。会话存活在那一个进程的内存里。 没有服务器 bug 可找。HTTP 响应是 `404`,它的响应体**是** JSON-RPC,所以和上面的 `421` 不同,python `Client` 会把这一条原样展示出来: @@ -255,9 +261,9 @@ app = Starlette(routes=[Mount("/", app=mcp.streamable_http_app())], lifespan=lif 修复是重连:离开 `async with Client(...)` 块,进入一个新的,它会协商一个全新的会话。对于长时间运行的客户端,这意味着在调用外面捕获 `MCPError`,遇到这条消息就重连,而不是在死掉的会话里重试。 -如果**没有**重启也发生,说明你跑了不止一个 worker 却没有粘性会话:每个 worker 持有自己的会话表,所以路由到错误 worker 的请求就落到这里。这件事以及它的两种修复(粘性路由,或 `stateless_http=True`)归 **[部署与扩展](run/deploy.md)** 和 **[服务旧版客户端](run/legacy-clients.md)** 管。 +如果**没有**重启、客户端也没有沉默那么久却仍然发生,说明你跑了不止一个 worker 却没有粘性会话:每个 worker 持有自己的会话表,所以路由到错误 worker 的请求就落到这里。这件事以及它的两种修复(粘性路由,或 `stateless_http=True`)归 **[部署与扩展](run/deploy.md)** 和 **[服务旧版客户端](run/legacy-clients.md)** 管。 -对服务器运维方来说,对应的日志行是 `Rejected request with unknown or expired session ID: `。它以 `INFO` 级别记录,所以在常用的 `WARNING` 阈值下看不到。部署后马上成批出现是正常的;每个已连接的客户端都在重连。 +对服务器运维方来说,对应的日志行是 `Rejected request with unknown or expired session ID: `。它以 `INFO` 级别记录,所以在常用的 `WARNING` 阈值下看不到。部署后马上成批出现是正常的;每个已连接的客户端都在重连。如果是会话过期,那一行前面会先有一条 `Session idle timeout`,同样是 `INFO` 级别。 ## `MCPError: Method not found` {#mcperror-method-not-found} @@ -269,7 +275,13 @@ app = Starlette(routes=[Mount("/", app=mcp.streamable_http_app())], lifespan=lif 服务器想问用户点什么,而这个客户端从没说过自己可以被问。 -征询(elicitation)解析器在已连接的客户端没有声明表单征询时会一开始就拒绝,`e.error.data` 会准确指出缺了什么: +这家 Bistro 在订位之前会通过一个解析器先问一句: + +```python title="server.py" hl_lines="15-17 21" +--8<-- "docs_src/troubleshooting/tutorial007.py" +``` + +用它替换 Weather 服务器来运行,然后从一个没有传 `elicitation_callback` 的客户端调用 `book_table`。解析器会一开始就拒绝,因为已连接的客户端从没声明过表单征询(elicitation),`e.error.data` 会准确指出缺了什么: ```json { @@ -283,7 +295,7 @@ app = Starlette(routes=[Mount("/", app=mcp.streamable_http_app())], lifespan=lif ```python async def main() -> None: - async with Client(mcp, elicitation_callback=handle_elicitation) as client: + async with Client("http://localhost:8000/mcp", elicitation_callback=handle_elicitation) as client: result = await client.call_tool("book_table", {"date": "Friday"}) ``` @@ -302,14 +314,14 @@ async def main() -> None: 处理函数试图在请求中途联系客户端,而在这条连接上,这次调用没有任何能承载服务器发出请求的通道。有三种服务器配置会把调用置于这种境地。 -**`2026-07-28` 连接:任何传输方式,永远如此。** 现代协议根本没有服务器发起的请求,所以服务器在发送任何东西之前就拒绝了。工具里的 `ctx.elicit()` 是遇到它的经典方式(就在第一次内存测试里,因为 `Client(server)` 不用要求就会协商 `2026-07-28`),而传入 `elicitation_callback=` 什么也改变不了,因为根本没有请求到达客户端让它去回答: +**`2026-07-28` 连接:任何传输方式,永远如此。** 现代协议根本没有服务器发起的请求,所以服务器在发送任何东西之前就拒绝了。工具里的 `ctx.elicit()` 是遇到它的经典方式,通常就在这个工具的第一个内存 **[测试](get-started/testing.md)** 里,因为 `Client(mcp)` 不用要求就会协商 `2026-07-28`。传入 `elicitation_callback=` 什么也改变不了,因为根本没有请求到达客户端让它去回答: ```python title="server.py" hl_lines="16" --8<-- "docs_src/troubleshooting/tutorial006.py" ``` ```python -async def main() -> None: +async def test_book_table() -> None: async with Client(mcp) as client: await client.call_tool("book_table", {"date": "Friday"}) ``` @@ -347,7 +359,7 @@ mcp.shared.exceptions.MCPError: Cannot send 'elicitation/create': this transport ```python async def main() -> None: - async with Client(mcp) as client: + async with Client("http://localhost:8000/mcp") as client: await client.call_tool("forecast", {"city": "London"}, request_state="round-1-from-worker-a") ``` @@ -400,7 +412,7 @@ mcp = MCPServer("Weather", request_state_security=RequestStateSecurity(keys=[key * 服务器日志里的 `Tool already exists:` 是两个同名工具合并成一个的唯一迹象。 * 一个 421,三种写法:`Server returned an error response`(python `Client`)、`421 Misdirected Request` / `Invalid Host header`(其他所有地方)、`Invalid Host header: `(服务器日志)。修复:`transport_security=TransportSecuritySettings(allowed_hosts=[...])`。 * `Task group is not initialized` -> 被挂载的应用,其宿主生命周期从未进入 `mcp.session_manager.run()`。 -* `Session not found` -> 服务器重启了;重连。 +* `Session not found` -> 服务器重启了或会话过期了(`session_idle_timeout`);重连。 * `Cannot send 'elicitation/create': ... no back-channel ...` -> `ctx.elicit()` 需要一条服务器到客户端的通道:`2026-07-28` 连接永远没有,`stateless_http=True` 拿走了旧版的那条,`json_response=True` 拿走了请求级的那条。用解析器(旧版客户端还需要一个保留该通道的服务器)。它的邻居 `Method not found` 是请求了对方协议修订版里没有的方法。 * `Client did not declare the form elicitation capability ...` 和 `Elicitation not supported` -> 客户端缺少 `elicitation_callback=`。 * `Invalid or expired requestState` 在线路上从不说明原因。服务器日志会说;`unknown key` 意味着要在各 worker 间共享 `RequestStateSecurity(keys=[...])`。 diff --git a/i18n/zh/pages/whats-new.md b/i18n/zh/pages/whats-new.md index fa2be14c..d3445d69 100644 --- a/i18n/zh/pages/whats-new.md +++ b/i18n/zh/pages/whats-new.md @@ -1,6 +1,6 @@ --- translation: - sections: [cfe01c0c5863dfa2, 1c58c5cfcc37d455, a7392996acf1ad8f, 875eb2889263424e] + sections: [cfe01c0c5863dfa2, 0dbb68d8b210177b, 80cf193023af3ed4, 875eb2889263424e] tool: 1 --- # v2 的新变化 {#whats-new-in-v2} @@ -37,11 +37,11 @@ mcp = MCPServer("Demo") # v1: FastMCP("Demo") v1 交给你的是三层嵌套:一个产出原始流的传输上下文管理器,包在外面的 `ClientSession`,再加上手动调用的 `await session.initialize()`。v2 只有一个对象: -```python title="client.py" hl_lines="14-18" ---8<-- "docs_src/client/tutorial001.py" +```python title="client.py" hl_lines="7-11" +--8<-- "docs_src/client/tutorial001_client.py" ``` -`Client` 接受一个服务器对象(内存直连,没有传输:这就是测试的做法)、一个 URL(Streamable HTTP)、一个 `StdioServerParameters`(stdio 子进程),或者其他任意传输上下文管理器,比如 `sse_client(...)`。进入 `async with` 就会建立连接并协商协议版本,不管服务器讲的是哪一代协议;之后 `client.server_capabilities` 和 `client.protocol_version` 直接就在那里,服务器表明身份时 `client.server_info` 也一样(它现在是 `Implementation | None`,因为 2026 版的身份信息是可选的)。你在 v1 注册的采样和征询回调仍然能用(它们的函数体会看到和本页其他地方一样的 snake_case 属性重命名),现在还会回答 2026 风格的、嵌在结果里的请求(见下文),并且是并发运行而不是一次一个。想要底层接口的人仍然可以用底下的 `ClientSession`,`client.session` 会把它交给你;它也变了(运行在新的调度器引擎上,自身的一些签名也改了),所以下探之前先读 **[迁移指南](migration.md#clientsession-now-runs-on-jsonrpcdispatcher-basesession-removed)**。 +`Client` 接受一个 URL(Streamable HTTP)、一个 `StdioServerParameters`(stdio 子进程)、其他任意传输上下文管理器(比如 `sse_client(...)`),或者在测试里直接接受服务器对象本身(内存直连,没有传输)。进入 `async with` 就会建立连接并协商协议版本,不管服务器讲的是哪一代协议;之后 `client.server_capabilities` 和 `client.protocol_version` 直接就在那里,服务器表明身份时 `client.server_info` 也一样(它现在是 `Implementation | None`,因为 2026 版的身份信息是可选的)。你在 v1 注册的采样和征询回调仍然能用(它们的函数体会看到和本页其他地方一样的 snake_case 属性重命名),现在还会回答 2026 风格的、嵌在结果里的请求(见下文),并且是并发运行而不是一次一个。想要底层接口的人仍然可以用底下的 `ClientSession`,`client.session` 会把它交给你;它也变了(运行在新的调度器引擎上,自身的一些签名也改了),所以下探之前先读 **[迁移指南](migration.md#clientsession-now-runs-on-jsonrpcdispatcher-basesession-removed)**。 **[Client](client/index.md)** 介绍它,**[客户端传输](client/transports.md)** 讲四种连接形式,**[客户端回调](client/callbacks.md)** 讲回调本身,**[测试](get-started/testing.md)** 展示取代 v1 `create_connected_server_and_client_session()` 辅助函数的内存模式。 @@ -166,11 +166,15 @@ v2 实现了 2026-07-28 修订版,并且同时服务 **两个** 修订版: 替代方案把调用反了过来。需要从用户那里拿东西的工具把问题 **返回** 出去(`InputRequiredResult`),客户端用它一直都有的那些回调来回答,然后调用会带着答案重试。`Client` 替你驱动这个循环。在服务器上你很少自己构建这个结果,因为 **[依赖](handlers/dependencies.md)** 会做这件事:用 `Resolve(ask_quantity)` 注解一个参数,其中 `ask_quantity` 是你写的普通函数,SDK 就会通过连接所支持的机制去问——在旧版会话上是实时的征询请求,在 2026 上是多轮往返。一个工具函数体,两代协议: -```python title="dual_era.py" hl_lines="24 37-38" +```python title="server.py" hl_lines="21" --8<-- "docs_src/legacy_clients/tutorial001.py" ``` -这个文件把卖点集中在了一处:一个服务器,一个由 `Resolve` 支撑的工具,一个旧版客户端加一个新版客户端都拿到了各自的答案,全在内存里。**[多轮往返请求](handlers/multi-round-trip.md)** 解释这个机制(包括 `request_state`,SDK 会替你密封并验证它);**[征询](handlers/elicitation.md)** 讲提问的部分。 +```python title="client.py" hl_lines="14-15" +--8<-- "docs_src/legacy_clients/tutorial001_client.py" +``` + +这两个文件就是全部卖点:一个服务器,一个由 `Resolve` 支撑的工具,一个旧版客户端加一个新版客户端都从同一个正在运行的服务器拿到了各自的答案(**[服务旧版客户端](run/legacy-clients.md)** 会逐步讲解它们)。**[多轮往返请求](handlers/multi-round-trip.md)** 解释这个机制(包括 `request_state`,SDK 会替你密封并验证它);**[征询](handlers/elicitation.md)** 讲提问的部分。 !!! warning "这是移植过来的 v1 服务器唯一会改变行为的地方" 你自己的测试会最先碰到它:`Client(mcp)` 默认会和你的 v2 服务器协商出 2026-07-28,所以调用 `ctx.elicit()` 的工具会在一个 v1 上能通过的测试里失败。把问题挪进一个 `Resolve(...)` 参数(两代通用),或者如果你确实想要推送行为,就把测试客户端固定为 `mode="legacy"`。 diff --git a/scripts/docs/translations.py b/scripts/docs/translations.py index 4dd4cfd0..59d454e1 100644 --- a/scripts/docs/translations.py +++ b/scripts/docs/translations.py @@ -13,12 +13,13 @@ English section hashes it reflects; nothing else tracks state. Usage (from the repository root): python scripts/docs/translations.py status [--lang CODE] - python scripts/docs/translations.py translate --lang CODE [--pages PATH ...] + python scripts/docs/translations.py translate [--lang CODE ...] [--pages PATH ...] [--jobs N] python scripts/docs/translations.py stage [--lang CODE] Only `translate` calls the model (credentials come from the environment, e.g. -`ANTHROPIC_API_KEY`) and needs the `translate` dependency group; -`DOCS_TRANSLATE_MODEL`, if set, replaces the registry's `model` for that run. +`ANTHROPIC_API_KEY`) and needs the `translate` dependency group; it keeps +`--jobs` pages in flight at once, and `DOCS_TRANSLATE_MODEL`, if set, replaces +the registry's `model` for that run. Exit codes: 0 done, 1 some page failed, 2 configuration or credential error. """ @@ -31,9 +32,12 @@ import posixpath import re import shutil import sys -from collections import Counter +import threading +from collections import Counter, deque from collections.abc import Callable, Iterator, Sequence +from concurrent.futures import FIRST_COMPLETED, Future, ThreadPoolExecutor, wait from dataclasses import dataclass, field +from itertools import chain, zip_longest from pathlib import Path from typing import Any, Literal, Protocol, cast, get_args @@ -54,6 +58,12 @@ TOOL_VERSION = 1 OUTPUT_TOKEN_BUDGET = 64_000 # Repair turns fed back to the model after the first reply before a page fails. MAX_REPAIRS = 2 +# Pages in flight at once unless `--jobs` says otherwise: each page is its own +# conversation, so concurrency changes nothing the model sees, only wall-clock time. +DEFAULT_JOBS = 8 +# Retries (with backoff) the API client makes on rate limits and overloads before +# a request fails its page; generous, since many pages share one rate limit. +API_RETRIES = 6 NOTICES_PAGE = "i18n/notices.md" # The nav page the notices link to for how the translations are made. TRANSLATIONS_DOC = "translations.md" @@ -385,6 +395,8 @@ class Repo: prose_pages: list[str] translatable: list[str] renderer: markdown.Markdown + # python-markdown instances are not thread-safe; pages render one at a time. + _render_lock: threading.Lock = field(default_factory=threading.Lock, repr=False) def language(self, code: str) -> Language: for language in self.registry.languages: @@ -413,10 +425,12 @@ class Repo: Raises: PageError: The renderer sees headings the source scan does not (setext, indented, HTML). """ - self.renderer.reset() - self.renderer.convert(body) - tokens = cast("list[dict[str, Any]]", getattr(self.renderer, "toc_tokens", [])) - ids, found = [str(token["id"]) for token in _flatten(tokens)], parse_headings(body) + with self._render_lock: + self.renderer.reset() + self.renderer.convert(body) + tokens = cast("list[dict[str, Any]]", getattr(self.renderer, "toc_tokens", [])) + ids = [str(token["id"]) for token in _flatten(tokens)] + found = parse_headings(body) if len(ids) != len(found): raise PageError(f"the page renders {len(ids)} headings but {len(found)} are ATX headings at column 0") return ids @@ -579,7 +593,10 @@ class Completion: class Translator(Protocol): - """Anything that answers a conversation (`ConfigError`: credentials rejected; `PageError`: request failed).""" + """Anything that answers a conversation (`ConfigError`: credentials rejected; `PageError`: request failed). + + `complete` is called from several threads at once when pages run in parallel. + """ def complete(self, *, model: str, system: str, messages: Sequence[Message], max_tokens: int) -> Completion: ... @@ -587,14 +604,22 @@ class Translator(Protocol): def anthropic_translator() -> Translator: """The Claude Messages API client, streaming, with the system prompt as one cached block. - `anthropic` lives in the non-default `translate` dependency group, so it is - imported here, by name: offline commands and type checking never need it. + One client serves every thread. The system prompt is everything a language's + pages share (rules, instructions, glossary), so it is the cacheable prefix: + the first request of a language writes it and the rest read it. A page that + starts before that first reply has begun streaming writes it again instead; + `command_translate` orders the work so that is rare, and nothing waits on it. + + `anthropic` (and `httpx`, its transport, whose errors can escape it) lives in + the non-default `translate` dependency group, so both are imported here, by + name: offline commands and type checking never need them. Raises: ConfigError: The `translate` dependency group is not installed, or no credentials are configured. """ try: sdk = importlib.import_module("anthropic") + httpx = importlib.import_module("httpx") except ImportError as exc: raise ConfigError( "the anthropic package is not installed; run with `uv run --frozen --group translate`" @@ -602,7 +627,7 @@ def anthropic_translator() -> Translator: # The SDK resolves every credential source it knows at construction; fail # here, before any page work, rather than on the first request. try: - client = sdk.Anthropic() + client = sdk.Anthropic(max_retries=API_RETRIES) except sdk.AnthropicError as exc: # e.g. a credential profile it was pointed at is unreadable raise ConfigError(f"cannot set up the API client: {exc}") from exc if not (client.api_key or client.auth_token or client.credentials): @@ -619,6 +644,8 @@ def anthropic_translator() -> Translator: raise ConfigError(f"the API rejected the credentials: {exc.message}") from exc except sdk.APIError as exc: raise PageError(f"API request failed: {exc.message}") from exc + except httpx.HTTPError as exc: # the connection failing mid-reply is not wrapped by the SDK + raise PageError(f"API connection failed: {exc!r}") from exc usage = Usage( reply.usage.input_tokens, reply.usage.output_tokens, @@ -926,30 +953,78 @@ def translate_page(repo: Repo, inputs: Inputs, job: Job, translator: Translator, def command_translate(repo: Repo, args: argparse.Namespace, translator: Translator | None) -> int: - language = repo.language(args.lang) - inputs = repo.inputs(language) - jobs = select_jobs([classify(page) for page in repo.pages(language)], args.pages) - if not jobs: - print(f"{language.code}: nothing to translate") + codes = list(dict.fromkeys(args.lang)) # each language once, in the order given + languages = [repo.language(code) for code in codes] if codes else repo.registry.languages + per_language: list[list[tuple[Inputs, Job]]] = [] + for language in languages: + inputs = repo.inputs(language) + jobs = select_jobs([classify(page) for page in repo.pages(language)], args.pages) + if not jobs: + print(f"{language.code}: nothing to translate") + per_language.append([(inputs, job) for job in jobs]) + # Page-major across languages (every language's first page, then every second page, ...), + # so each language's first request is under way, and its cached prefix written, before + # its next page goes out, however many pages are in flight. + work = [item for item in chain.from_iterable(zip_longest(*per_language)) if item is not None] + if not work: return 0 model = os.environ.get("DOCS_TRANSLATE_MODEL") or repo.registry.model # never recorded in the generated files # Only a job with open sections calls the model; a run without one needs no client and no # credentials. Otherwise both are set up here, so bad credentials fail before any page work. - if translator is None and any(job.open for job in jobs): + if translator is None and any(job.open for _, job in work): translator = anthropic_translator() - usage, failed = Usage(), False - for job in jobs: - page = job.state.page + + def produce(inputs: Inputs, job: Job) -> tuple[str | PageError | ConfigError, Usage]: + """The page body, or why it failed or must stop the run, with the tokens it cost; runs on a pool thread.""" + spent = Usage() try: - body = translate_page(repo, inputs, job, translator, model, usage) if translator else reassemble(repo, job) - except PageError as exc: - failed = True - print(f"error: {page.key}: {exc}", file=sys.stderr) - continue - page.target.parent.mkdir(parents=True, exist_ok=True) - page.target.write_text(with_provenance(body, job.state.hashes), encoding="utf-8", newline="\n") - print(f"translated: {page.key} ({len(job.open)} of {len(job.state.hashes)} sections)", flush=True) + body = translate_page(repo, inputs, job, translator, model, spent) if translator else reassemble(repo, job) + except (PageError, ConfigError) as exc: + return exc, spent + return body, spent + + active = sorted({inputs.language.code for inputs, _ in work}, key=[lang.code for lang in languages].index) + print(f"translating {len(work)} pages ({', '.join(active)}), {args.jobs} at a time, with {model}", flush=True) + usage, failed = Usage(), False + queue = deque(work) + running: dict[Future[tuple[str | PageError | ConfigError, Usage]], tuple[Language, Job]] = {} + stopped: ConfigError | None = None # rejected credentials: start nothing more, keep what still lands + pool = ThreadPoolExecutor(max_workers=args.jobs) + try: + # The window is refilled here rather than handing the pool every page up front, so + # that once the credentials are rejected no further page starts; pages already in + # flight are still collected, and written if they made it, before the run stops. + while (queue and stopped is None) or running: + while queue and stopped is None and len(running) < args.jobs: + inputs, job = queue.popleft() + running[pool.submit(produce, inputs, job)] = (inputs.language, job) + for future in wait(running, return_when=FIRST_COMPLETED).done: + language, job = running.pop(future) + result, spent = future.result() + usage.add(spent) + if isinstance(result, ConfigError): + stopped = stopped or result + continue + page = job.state.page + if isinstance(result, PageError): + failed = True + print(f"{language.code}: error: {page.key}: {result}", file=sys.stderr) + continue + page.target.parent.mkdir(parents=True, exist_ok=True) + page.target.write_text(with_provenance(result, job.state.hashes), encoding="utf-8", newline="\n") + done = f"{len(job.open)} of {len(job.state.hashes)} sections" + print(f"{language.code}: translated {page.key} ({done})", flush=True) + except KeyboardInterrupt: + # The pool's threads are not daemons: left to them, the process would sit until every + # page in flight finished (and was thrown away). Leave now instead. + print(f"interrupted: {len(running)} pages in flight abandoned, {len(queue)} not started", file=sys.stderr) + print(f"usage: {usage}", flush=True) + os._exit(130) + finally: + pool.shutdown(wait=False) print(f"usage: {usage}") + if stopped is not None: + raise stopped return 1 if failed else 0 @@ -1072,6 +1147,12 @@ def command_status(repo: Repo, args: argparse.Namespace) -> int: # ---- Command line ---- +def _positive(text: str) -> int: + if (value := int(text)) < 1: + raise argparse.ArgumentTypeError("must be at least 1") + return value + + def _parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser( prog="translations.py", description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter @@ -1080,9 +1161,14 @@ def _parser() -> argparse.ArgumentParser: status = commands.add_parser("status", help="what each language is missing") status.add_argument("--lang", metavar="CODE") translate = commands.add_parser("translate", help="translate missing and outdated pages (calls the model)") - translate.add_argument("--lang", metavar="CODE", required=True) translate.add_argument( - "--pages", nargs="+", metavar="PATH", default=[], help="re-translate exactly these pages from scratch" + "--lang", nargs="+", action="extend", metavar="CODE", default=[], help="only these (default: every language)" + ) + translate.add_argument( + "--pages", nargs="+", action="extend", metavar="PATH", default=[], help="re-translate exactly these, afresh" + ) + translate.add_argument( + "--jobs", type=_positive, metavar="N", default=DEFAULT_JOBS, help=f"pages in flight (default {DEFAULT_JOBS})" ) staged = commands.add_parser("stage", help="assemble .build/i18n/CODE/docs for the site build") staged.add_argument("--lang", metavar="CODE", help="stage this language only (default: every language)") diff --git a/tests/docs/test_translations.py b/tests/docs/test_translations.py index 81ba1cb8..b5eddae0 100644 --- a/tests/docs/test_translations.py +++ b/tests/docs/test_translations.py @@ -9,6 +9,7 @@ repository a `tmp_path` tree, so every test is offline and deterministic. """ import json +import threading from collections.abc import Sequence from pathlib import Path @@ -197,15 +198,24 @@ class FakeTranslator: def run( capsys: pytest.CaptureFixture[str], root: Path, *argv: str, translator: t.Translator | None = None ) -> tuple[int, str, str]: + """`(exit code, stdout, stderr)` of one command line.""" code = t.main(list(argv), root=root, translator=translator) captured = capsys.readouterr() return code, captured.out, captured.err +def translate( + capsys: pytest.CaptureFixture[str], root: Path, *argv: str, jobs: int = 1, translator: t.Translator | None = None +) -> tuple[int, str, str]: + """`translate ARGV --jobs JOBS`: one page at a time by default, so scripted replies pair with pages in + nav order and the log has one possible order.""" + return run(capsys, root, "translate", *argv, "--jobs", str(jobs), translator=translator) + + def translate_all(capsys: pytest.CaptureFixture[str], root: Path) -> None: """Publish faithful translations of the three pages and the notices through the real command.""" fake = FakeTranslator([INDEX_JA, TOOLS_JA, TRANSLATIONS_JA, NOTICES_JA]) - assert run(capsys, root, "translate", "--lang", "ja", translator=fake)[0] == 0 + assert translate(capsys, root, "--lang", "ja", translator=fake)[0] == 0 def test_sections_tile_the_page_and_blank_lines_belong_to_the_heading_after_them() -> None: @@ -552,7 +562,7 @@ def test_glossary_of_the_wrong_shape_stops_translate_with_exit_2_but_never_break path = root / "i18n" / "ja" / "glossary.json" write(path, json.dumps(glossary)) - code, out, err = run(capsys, root, "translate", "--lang", "ja", translator=FakeTranslator([])) + code, out, err = translate(capsys, root, "--lang", "ja", translator=FakeTranslator([])) assert (code, out) == (2, "") assert err.startswith(f"translations: {path}: {message}") # an exception's own text is the interpreter's @@ -567,14 +577,15 @@ def test_translate_writes_pages_with_provenance_and_a_second_run_makes_no_calls( root = make_repo(tmp_path) fake = FakeTranslator([INDEX_JA, TOOLS_JA, TRANSLATIONS_JA, NOTICES_JA]) - code, out, err = run(capsys, root, "translate", "--lang", "ja", translator=fake) + code, out, err = translate(capsys, root, "--lang", "ja", translator=fake) assert (code, err) == (0, "") assert out == snapshot("""\ -translated: index.md (2 of 2 sections) -translated: tools.md (3 of 3 sections) -translated: translations.md (1 of 1 sections) -translated: i18n/notices.md (4 of 4 sections) +translating 4 pages (ja), 1 at a time, with test-model +ja: translated index.md (2 of 2 sections) +ja: translated tools.md (3 of 3 sections) +ja: translated translations.md (1 of 1 sections) +ja: translated i18n/notices.md (4 of 4 sections) usage: 4000 input / 1600 output / 3600 cache-write / 400 cache-read tokens """) assert (root / "i18n" / "ja" / "pages" / "tools.md").read_text(encoding="utf-8") == snapshot("""\ @@ -621,7 +632,7 @@ Use these renderings; the notes are binding: - server → サーバー. Katakana, long vowel kept.\ """) - code, out, err = run(capsys, root, "translate", "--lang", "ja") + code, out, err = translate(capsys, root, "--lang", "ja") assert (code, out, err) == (0, "ja: nothing to translate\n", "") assert run(capsys, root, "status") == snapshot( @@ -637,10 +648,10 @@ def test_docs_translate_model_overrides_the_registry_model_for_the_run( root = make_repo(tmp_path) monkeypatch.delenv("DOCS_TRANSLATE_MODEL", raising=False) fake = FakeTranslator([INDEX_JA, INDEX_JA]) - assert run(capsys, root, "translate", "--lang", "ja", "--pages", "index.md", translator=fake)[0] == 0 + assert translate(capsys, root, "--lang", "ja", "--pages", "index.md", translator=fake)[0] == 0 monkeypatch.setenv("DOCS_TRANSLATE_MODEL", "trial-model") - code, _, _ = run(capsys, root, "translate", "--lang", "ja", "--pages", "index.md", translator=fake) + code, _, _ = translate(capsys, root, "--lang", "ja", "--pages", "index.md", translator=fake) assert (code, fake.models) == (0, ["test-model", "trial-model"]) assert "model" not in (root / "i18n" / "ja" / "pages" / "index.md").read_text(encoding="utf-8") @@ -656,14 +667,14 @@ def test_translate_pages_retranslates_the_named_pages_from_scratch_even_when_a_t translate_all(capsys, root) fake = FakeTranslator([INDEX_JA.replace("へようこそ", "へようこそ!")]) - code, out, _ = run(capsys, root, "translate", "--lang", "ja", "--pages", "index.md", translator=fake) + code, out, _ = translate(capsys, root, "--lang", "ja", "--pages", "index.md", translator=fake) - assert (code, out.split("\n")[0]) == (0, "translated: index.md (2 of 2 sections)") + assert (code, out.split("\n")[1]) == (0, "ja: translated index.md (2 of 2 sections)") assert fake.conversations[0] == [t.Message("user", t.translate_request(INDEX))] assert "MCP へようこそ!" in (root / "i18n" / "ja" / "pages" / "index.md").read_text(encoding="utf-8") assert run(capsys, root, "status")[1] == snapshot("ja (日本語): 0 missing, 0 outdated, 4 current, 0 removable\n") - code, _, err = run(capsys, root, "translate", "--lang", "ja", "--pages", "nope.md", translator=fake) + code, _, err = translate(capsys, root, "--lang", "ja", "--pages", "nope.md", translator=fake) assert (code, err) == snapshot( (2, "translations: not translatable pages (nav paths such as servers/tools.md): ['nope.md']\n") @@ -684,9 +695,9 @@ def test_outdated_page_retranslates_the_changed_section_and_carries_the_rest_for ) fake = FakeTranslator([reply]) - code, out, _ = run(capsys, root, "translate", "--lang", "ja", translator=fake) + code, out, _ = translate(capsys, root, "--lang", "ja", translator=fake) - assert (code, out.split("\n")[0]) == (0, "translated: tools.md (1 of 3 sections)") + assert (code, out.split("\n")[1]) == (0, "ja: translated tools.md (1 of 3 sections)") assert fake.conversations[0][0].content == snapshot("""\ This page was translated before. Retranslate it: translate the sections listed below afresh from the current English, applying the current language instructions and glossary @@ -765,9 +776,9 @@ def test_banned_rendering_is_a_finding_in_a_retranslated_section_but_not_in_a_ca repaired = TOOLS_JA.replace("失敗を伝えるには例外を送出します。", "ファンクションから送出します。") fake = FakeTranslator([slipped, repaired]) - code, out, err = run(capsys, root, "translate", "--lang", "ja", translator=fake) + code, out, err = translate(capsys, root, "--lang", "ja", translator=fake) - assert (code, err, fake.replies, out.split("\n")[0]) == (0, "", [], "translated: tools.md (1 of 3 sections)") + assert (code, err, fake.replies, out.split("\n")[1]) == (0, "", [], "ja: translated tools.md (1 of 3 sections)") assert fake.conversations[1][2].content == snapshot("""\ Your translation broke the following structural rules. Fix each problem and return the full corrected page, changing nothing else: @@ -793,9 +804,9 @@ def test_link_dropped_in_a_carried_section_costs_no_repair_turn_and_the_stored_s ) fake = FakeTranslator([reply]) - code, out, err = run(capsys, root, "translate", "--lang", "ja", translator=fake) + code, out, err = translate(capsys, root, "--lang", "ja", translator=fake) - assert (code, err, out.split("\n")[0]) == (0, "", "translated: tools.md (1 of 3 sections)") + assert (code, err, out.split("\n")[1]) == (0, "", "ja: translated tools.md (1 of 3 sections)") assert [len(conversation) for conversation in fake.conversations] == [1] # one call, no repair turn body = t.split_front_matter((root / "i18n" / "ja" / "pages" / "tools.md").read_text(encoding="utf-8"))[1] assert t.sections(body)[0] == t.sections(TOOLS_JA)[0].replace("# ツール\n", "# ツール {#tools}\n") @@ -819,9 +830,9 @@ def test_removed_english_section_is_reassembled_with_no_client_but_an_edited_one monkeypatch.setattr(t, "anthropic_translator", no_credentials) write(root / "docs" / "tools.md", TOOLS.split("## Errors")[0].rstrip("\n") + "\n") - code, out, _ = run(capsys, root, "translate", "--lang", "ja") + code, out, _ = translate(capsys, root, "--lang", "ja") - assert (code, out.split("\n")[0]) == (0, "translated: tools.md (0 of 2 sections)") + assert (code, out.split("\n")[1]) == (0, "ja: translated tools.md (0 of 2 sections)") body = t.split_front_matter((root / "i18n" / "ja" / "pages" / "tools.md").read_text(encoding="utf-8"))[1] previous = TOOLS_JA.split("## エラー")[0].rstrip("\n") + "\n" assert body == previous.replace("# ツール\n", "# ツール {#tools}\n").replace( @@ -831,7 +842,7 @@ def test_removed_english_section_is_reassembled_with_no_client_but_an_edited_one write(root / "docs" / "tools.md", TOOLS.split("## Your first tool")[0].rstrip("\n") + "\n") write(root / "docs" / "index.md", INDEX.replace("Welcome to MCP.", "Welcome!")) - code, out, err = run(capsys, root, "translate", "--lang", "ja") + code, out, err = translate(capsys, root, "--lang", "ja") assert (code, out, err) == snapshot((2, "", "translations: no API credentials: set ANTHROPIC_API_KEY\n")) assert run(capsys, root, "status", "--lang", "ja")[1] == snapshot("""\ @@ -841,10 +852,10 @@ ja (日本語): 0 missing, 2 outdated, 2 current, 0 removable """) fake = FakeTranslator([INDEX_JA.replace("MCP へようこそ。", "ようこそ!")]) - code, out, err = run(capsys, root, "translate", "--lang", "ja", translator=fake) + code, out, err = translate(capsys, root, "--lang", "ja", translator=fake) - assert (code, err, fake.replies, out.split("\n")[:2]) == snapshot( - (0, "", [], ["translated: index.md (1 of 2 sections)", "translated: tools.md (0 of 1 sections)"]) + assert (code, err, fake.replies, out.split("\n")[1:3]) == snapshot( + (0, "", [], ["ja: translated index.md (1 of 2 sections)", "ja: translated tools.md (0 of 1 sections)"]) ) @@ -858,19 +869,20 @@ def test_a_failing_page_does_not_stop_the_run_and_the_exit_code_is_1( truncated = t.Completion(TOOLS_JA[:40], t.Usage(10, 64_000, 0, 0), "max_tokens") fake = FakeTranslator([t.PageError("API request failed: overloaded"), truncated, refusal, NOTICES_JA]) - code, out, err = run(capsys, root, "translate", "--lang", "ja", translator=fake) + code, out, err = translate(capsys, root, "--lang", "ja", translator=fake) assert (code, out, err) == snapshot( ( 1, """\ -translated: i18n/notices.md (4 of 4 sections) +translating 4 pages (ja), 1 at a time, with test-model +ja: translated i18n/notices.md (4 of 4 sections) usage: 1020 input / 64400 output / 900 cache-write / 100 cache-read tokens """, """\ -error: index.md: API request failed: overloaded -error: tools.md: the reply was cut off at 64000 output tokens -error: translations.md: the model declined to translate this page +ja: error: index.md: API request failed: overloaded +ja: error: tools.md: the reply was cut off at 64000 output tokens +ja: error: translations.md: the model declined to translate this page """, ) ) @@ -883,12 +895,185 @@ def test_rejected_credentials_stop_the_run_with_exit_2(tmp_path: Path, capsys: p root = make_repo(tmp_path) fake = FakeTranslator([t.ConfigError("the API rejected the credentials: invalid x-api-key"), INDEX_JA]) - code, out, err = run(capsys, root, "translate", "--lang", "ja", translator=fake) + code, out, err = translate(capsys, root, "--lang", "ja", translator=fake) - assert (code, out, err) == snapshot((2, "", "translations: the API rejected the credentials: invalid x-api-key\n")) + assert (code, out, err) == snapshot( + ( + 2, + """\ +translating 4 pages (ja), 1 at a time, with test-model +usage: 0 input / 0 output / 0 cache-write / 0 cache-read tokens +""", + "translations: the API rejected the credentials: invalid x-api-key\n", + ) + ) assert fake.replies == [INDEX_JA] +def test_jobs_keeps_that_many_pages_in_flight_and_publishes_each_before_starting_another( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + """Tool-defined: with `--jobs 2` over four pages, two requests are always waiting on the model together + (neither of a pair is answered until both wait, from two threads), never three, and by the time the + third and fourth requests are made an earlier page is already on disk: a finished page is written + when it lands, and only then does the next one start.""" + root = make_repo(tmp_path) + generated = root / "i18n" / "ja" / "pages" + replies = {"# Home": INDEX_JA, "# Tools": TOOLS_JA, "# Translations": TRANSLATIONS_JA, "# Notices": NOTICES_JA} + + class Pairs: + """Answers requests two at a time, noting how many were in flight and what was published by then.""" + + def __init__(self) -> None: + self.both, self.lock = threading.Barrier(2, timeout=5), threading.Lock() + self.in_flight = self.most = 0 + self.published: list[list[str]] = [] + + def complete(self, *, model: str, system: str, messages: Sequence[t.Message], max_tokens: int) -> t.Completion: + with self.lock: + self.in_flight += 1 + self.most = max(self.most, self.in_flight) + self.published.append(sorted(path.name for path in generated.glob("*.md"))) + try: + self.both.wait() + title = next(title for title in replies if title in messages[0].content) + return t.Completion(replies[title], t.Usage(10, 4, 0, 9)) + finally: + with self.lock: + self.in_flight -= 1 + + fake = Pairs() + + code, out, err = translate(capsys, root, "--lang", "ja", jobs=2, translator=fake) + + assert (code, err, fake.most) == (0, "", 2) + assert fake.published[:2] == [[], []] + assert all(fake.published[2:]) and len(fake.published) == 4 + assert sorted(out.splitlines()) == [ + "ja: translated i18n/notices.md (4 of 4 sections)", + "ja: translated index.md (2 of 2 sections)", + "ja: translated tools.md (3 of 3 sections)", + "ja: translated translations.md (1 of 1 sections)", + "translating 4 pages (ja), 2 at a time, with test-model", + "usage: 40 input / 16 output / 0 cache-write / 36 cache-read tokens", + ] + assert run(capsys, root, "status") == (0, "ja (日本語): 0 missing, 0 outdated, 4 current, 0 removable\n", "") + + +class RejectedTogether: + """Lets two requests in together, then rejects the credentials of those whose page title is in `rejected`.""" + + def __init__(self, rejected: Sequence[str]) -> None: + self.rejected, self.calls = rejected, 0 + self.both, self.lock = threading.Barrier(2, timeout=5), threading.Lock() + + def complete(self, *, model: str, system: str, messages: Sequence[t.Message], max_tokens: int) -> t.Completion: + with self.lock: + self.calls += 1 + self.both.wait() + if any(title in messages[0].content for title in self.rejected): + raise t.ConfigError("the API rejected the credentials: invalid bearer token") + return t.Completion(TOOLS_JA, t.Usage(10, 4, 0, 9)) + + +def test_rejected_credentials_keep_a_page_that_lands_from_the_same_flight( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + """Tool-defined: with two pages in flight together, the credentials being rejected for one does not throw + away the other: whichever lands first, the good page is written and usage reported before exit 2.""" + root = make_repo(tmp_path) + fake = RejectedTogether(["# Home"]) + + code, out, err = translate(capsys, root, "--lang", "ja", "--pages", "index.md", "tools.md", jobs=2, translator=fake) + + assert (code, fake.calls, err) == (2, 2, "translations: the API rejected the credentials: invalid bearer token\n") + assert out == snapshot("""\ +translating 2 pages (ja), 2 at a time, with test-model +ja: translated tools.md (3 of 3 sections) +usage: 10 input / 4 output / 0 cache-write / 9 cache-read tokens +""") + assert sorted(path.name for path in (root / "i18n" / "ja" / "pages").glob("*.md")) == ["tools.md"] + + +def test_rejected_credentials_with_pages_in_flight_start_no_further_page( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + """Tool-defined: with `--jobs 2` over four pages, once the credentials are rejected (here for both pages in + flight) the two remaining pages are never requested and nothing is written.""" + root = make_repo(tmp_path) + fake = RejectedTogether(["# Home", "# Tools"]) + + code, out, err = translate(capsys, root, "--lang", "ja", jobs=2, translator=fake) + + assert (code, fake.calls, err) == (2, 2, "translations: the API rejected the credentials: invalid bearer token\n") + assert out == snapshot("""\ +translating 4 pages (ja), 2 at a time, with test-model +usage: 0 input / 0 output / 0 cache-write / 0 cache-read tokens +""") + assert not (root / "i18n" / "ja" / "pages").exists() + + +def test_translate_without_lang_works_through_every_language_with_that_languages_own_prompt( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + """Tool-defined: no `--lang` means every language in the registry, page by page across languages + (each language's first page before any second page), each page sent with its own language's system + prompt and reported under its code, with one usage total; `--lang` may be repeated and names a + language once however often it is given.""" + root = make_repo(tmp_path) + write( + root / "i18n" / "languages.yml", LANGUAGES + " - code: ko\n name: 한국어\n theme: ko\n hreflang: ko\n" + ) + write(root / "i18n" / "ko" / "instructions.md", "Korean rules.\n") + write(root / "i18n" / "ko" / "glossary.json", json.dumps({"keep": ["MCP"], "terms": []})) + fake = FakeTranslator( + [INDEX_JA, INDEX_JA, TOOLS_JA, TOOLS_JA, TRANSLATIONS_JA, TRANSLATIONS_JA, NOTICES_JA, NOTICES_JA] + ) + + code, out, err = translate(capsys, root, translator=fake) + + assert (code, err) == (0, "") + assert out == snapshot("""\ +translating 8 pages (ja, ko), 1 at a time, with test-model +ja: translated index.md (2 of 2 sections) +ko: translated index.md (2 of 2 sections) +ja: translated tools.md (3 of 3 sections) +ko: translated tools.md (3 of 3 sections) +ja: translated translations.md (1 of 1 sections) +ko: translated translations.md (1 of 1 sections) +ja: translated i18n/notices.md (4 of 4 sections) +ko: translated i18n/notices.md (4 of 4 sections) +usage: 8000 input / 3200 output / 7200 cache-write / 800 cache-read tokens +""") + assert [system.split("\n")[2] for system in fake.systems] == snapshot( + [ + "# Target language: 日本語 (`ja`)", + "# Target language: 한국어 (`ko`)", + "# Target language: 日本語 (`ja`)", + "# Target language: 한국어 (`ko`)", + "# Target language: 日本語 (`ja`)", + "# Target language: 한국어 (`ko`)", + "# Target language: 日本語 (`ja`)", + "# Target language: 한국어 (`ko`)", + ] + ) + assert fake.systems[1] == snapshot("""\ +General rules. + +# Target language: 한국어 (`ko`) + +Korean rules. + +## Glossary + +These terms always stay in English, spelled exactly like this: + +- MCP\ +""") + again = translate(capsys, root, "--lang", "ko", "--lang", "ja", "ko") + assert again == (0, "ko: nothing to translate\nja: nothing to translate\n", "") + + def test_repair_turn_feeds_the_findings_back_and_accepts_the_fixed_reply( tmp_path: Path, capsys: pytest.CaptureFixture[str] ) -> None: @@ -898,7 +1083,7 @@ def test_repair_turn_feeds_the_findings_back_and_accepts_the_fixed_reply( broken = TOOLS_JA.replace("`async` に", "非同期に").replace("!!! note", "!!! warning") fake = FakeTranslator([INDEX_JA, broken, TOOLS_JA, TRANSLATIONS_JA, NOTICES_JA]) - code, _, err = run(capsys, root, "translate", "--lang", "ja", translator=fake) + code, _, err = translate(capsys, root, "--lang", "ja", translator=fake) assert (code, err, fake.replies) == (0, "", []) assert [message.role for message in fake.conversations[2]] == ["user", "assistant", "user"] @@ -926,7 +1111,7 @@ def test_code_block_moved_into_another_section_is_repaired_not_published( root = make_repo(tmp_path) fake = FakeTranslator([FENCE_MOVED_JA, TOOLS_JA]) - code, _, err = run(capsys, root, "translate", "--lang", "ja", "--pages", "tools.md", translator=fake) + code, _, err = translate(capsys, root, "--lang", "ja", "--pages", "tools.md", translator=fake) assert (code, err, fake.replies) == (0, "", []) assert fake.conversations[1][2].content == snapshot("""\ @@ -954,9 +1139,9 @@ def test_code_block_moved_out_of_a_retranslated_section_gets_repair_turns_like_a moved = FENCE_MOVED_JA.replace("失敗を伝えるには例外を送出します。", "失敗するには `ToolError` を送出します。") fake = FakeTranslator([moved, fixed]) - code, out, err = run(capsys, root, "translate", "--lang", "ja", translator=fake) + code, out, err = translate(capsys, root, "--lang", "ja", translator=fake) - assert (code, err, fake.replies, out.split("\n")[0]) == (0, "", [], "translated: tools.md (1 of 3 sections)") + assert (code, err, fake.replies, out.split("\n")[1]) == (0, "", [], "ja: translated tools.md (1 of 3 sections)") assert fake.conversations[1][2].content == snapshot("""\ Your translation broke the following structural rules. Fix each problem and return the full corrected page, changing nothing else: @@ -983,7 +1168,7 @@ def test_shortened_list_and_table_are_fed_back_for_repair_before_the_page_is_pub shortened = LISTED_JA.replace(" 1. 入れ子\n* さん\n", "(以下同様)\n").replace("| c | d |\n", "") fake = FakeTranslator([shortened, LISTED_JA]) - code, _, err = run(capsys, root, "translate", "--lang", "ja", "--pages", "translations.md", translator=fake) + code, _, err = translate(capsys, root, "--lang", "ja", "--pages", "translations.md", translator=fake) assert (code, err, fake.replies) == (0, "", []) assert fake.conversations[1][2].content == snapshot("""\ @@ -1008,11 +1193,11 @@ def test_page_still_broken_after_two_repair_turns_fails_and_keeps_the_previous_t missing_fence = TOOLS_JA.replace('```python title="server.py"\n--8<-- "docs_src/server.py"\n```\n\n', "") fake = FakeTranslator([missing_fence] * 3) - code, _, err = run(capsys, root, "translate", "--lang", "ja", "--pages", "tools.md", translator=fake) + code, _, err = translate(capsys, root, "--lang", "ja", "--pages", "tools.md", translator=fake) assert (code, fake.replies) == (1, []) assert err == snapshot( - "error: tools.md: unfixed after 2 repairs: ## Your first tool: 0 code fences vs 1 in the English: keep each where it is, add none\n" + "ja: error: tools.md: unfixed after 2 repairs: ## Your first tool: 0 code fences vs 1 in the English: keep each where it is, add none\n" ) assert (root / "i18n" / "ja" / "pages" / "tools.md").read_text(encoding="utf-8") == before @@ -1058,7 +1243,7 @@ def test_stage_overlays_translations_injects_notices_and_rewrites_api_links( notice links so they hold under any path prefix, and assets ride along.""" root = make_repo(tmp_path) fake = FakeTranslator([INDEX_JA]) - assert run(capsys, root, "translate", "--lang", "ja", "--pages", "index.md", translator=fake)[0] == 0 + assert translate(capsys, root, "--lang", "ja", "--pages", "index.md", translator=fake)[0] == 0 write(root / "docs" / "api" / "mcp" / "index.md", "# API stub\n") code, out, err = run(capsys, root, "stage", "--lang", "ja") @@ -1185,7 +1370,7 @@ def test_stage_keeps_showing_the_generated_code_block_after_its_english_changes_ assert (code, err, staged.split("\n")[2]) == (0, "", '!!! note "英語版より古い翻訳"') assert ('title="server.py"' in staged, 'title="app.py"' in staged) == (True, False) - assert run(capsys, root, "translate", "--lang", "ja", translator=FakeTranslator([TOOLS_JA]))[0] == 0 + assert translate(capsys, root, "--lang", "ja", translator=FakeTranslator([TOOLS_JA]))[0] == 0 code, _, err = run(capsys, root, "stage", "--lang", "ja") staged = staged_page(root, "tools.md") From 9972c21aa42054fb1450c5fc614761ed11847ec6 Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Mon, 7 Sep 2026 17:40:21 +0200 Subject: [PATCH 19/20] Replace RootModel wrappers with type aliases and TypeAdapter validation (#3470) --- docs/servers/structured-output.md | 2 + pyproject.toml | 5 +- scripts/gen_surface_types.py | 33 +- .../mcp_types/_v2025_11_25/__init__.py | 411 ++-- .../mcp_types/_v2026_07_28/__init__.py | 1663 ++++++++--------- src/mcp/client/session.py | 11 +- src/mcp/server/auth/handlers/authorize.py | 12 +- src/mcp/server/elicitation.py | 5 +- .../mcpserver/utilities/func_metadata.py | 32 +- tests/types/test_methods.py | 19 +- tests/types/test_parity.py | 2 - 11 files changed, 970 insertions(+), 1225 deletions(-) diff --git a/docs/servers/structured-output.md b/docs/servers/structured-output.md index 792bcb0c..fbe09ba8 100644 --- a/docs/servers/structured-output.md +++ b/docs/servers/structured-output.md @@ -169,6 +169,8 @@ result.structured_content # {"London": 16.2, "Reykjavik": 4.4} The keys must be `str`. A `dict[int, float]` can't be a JSON object, so it falls back to the `{"result": ...}` wrapper. +Dictionary results use Pydantic's `TypeAdapter` for validation and serialization. If you inspect a tool's `FuncMetadata.output_model`, it holds the dictionary type annotation with its schema title. + ## Validation `output_schema` is not documentation. Whatever your function returns is **validated against it** before it leaves the server. diff --git a/pyproject.toml b/pyproject.toml index d12cb748..536ef527 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -219,6 +219,7 @@ ignore = ["PERF203"] [tool.ruff.lint.flake8-tidy-imports.banned-api] "pydantic.RootModel".msg = "Use `pydantic.TypeAdapter` instead." +"pydantic.root_model.RootModel".msg = "Use `pydantic.TypeAdapter` instead." [tool.ruff.lint.mccabe] @@ -228,8 +229,8 @@ max-complexity = 24 # Default is 10 "__init__.py" = ["F401"] # The mcp.types package is an alias that mirrors mcp_types namespaces by design. "src/mcp/types/*.py" = ["F403"] -# Generated by scripts/gen_surface_types.py: raw datamodel-codegen output (TID251 lifts the repo-wide RootModel ban for these generated validators). -"src/mcp-types/mcp_types/_v*/__init__.py" = ["D212", "E501", "I001", "TID251", "UP007", "UP037"] +# Generated by scripts/gen_surface_types.py. +"src/mcp-types/mcp_types/_v*/__init__.py" = ["D212", "E501", "I001", "UP007", "UP037"] "tests/server/mcpserver/test_func_metadata.py" = ["E501"] # Inline snapshots of the translation tool's output carry long status/prompt lines verbatim. "tests/docs/test_translations.py" = ["E501"] diff --git a/scripts/gen_surface_types.py b/scripts/gen_surface_types.py index a9887dd4..9e0279ff 100644 --- a/scripts/gen_surface_types.py +++ b/scripts/gen_surface_types.py @@ -5,13 +5,14 @@ writes the result to `src/mcp-types/mcp_types/_v/__init__.py` (the underscore marks these as internal validators, not public API) with only the fixes the raw output needs: a small JSON pre-patch for the known `number`-as-`integer` schema.json defect, a header, full URLs for the spec's -site-absolute doc links, and per-version epilogue aliases. Run with +site-absolute doc links, plain type aliases, and per-version epilogue aliases. Run with `uv run --frozen --group codegen python scripts/gen_surface_types.py [--check]`. """ from __future__ import annotations import argparse +import ast import difflib import hashlib import json @@ -195,6 +196,7 @@ def run_codegen(schema_path: Path, output_path: Path) -> None: "--use-annotated", "--use-field-description", "--use-schema-description", "--enum-field-as-literal", "all", "--use-union-operator", "--use-double-quotes", + "--use-type-alias", "--skip-root-model", "--extra-fields", "ignore", # JSON Schema `format` is annotation-only; codegen's defaults # (Base64Str, AnyUrl) over-assert and reject valid wire data. @@ -237,6 +239,10 @@ def build(entry: dict[str, str]) -> str: schema = json.loads((SCHEMA_DIR / f"{version}.json").read_text(encoding="utf-8")) patch_schema(schema, SCHEMA_PATCHES.get(version, [])) make_server_info_opaque(schema) + if "JSONValue" in schema["$defs"]: + # A single recursive alias avoids mutually recursive alias evaluation in type checkers. + assert schema["$defs"]["JSONValue"]["anyOf"][0] == {"$ref": "#/$defs/JSONObject"} + schema["$defs"]["JSONValue"]["anyOf"][0] = schema["$defs"]["JSONObject"] with tempfile.TemporaryDirectory() as tmp: patched = Path(tmp) / "schema.json" @@ -246,7 +252,27 @@ def build(entry: dict[str, str]) -> str: source = raw.read_text(encoding="utf-8") source = re.sub(r"\A# generated by datamodel-codegen:\n#[^\n]*\n", "", source) - source = re.sub(r"^class Model\(RootModel\[Any\]\):\n {4}root: Any\n+", "", source, count=1, flags=re.MULTILINE) + # Keep named aliases only for recursive types; other aliases remain ordinary Python types and unions. + for node in reversed(ast.parse(source).body): + if not ( + isinstance(node, ast.Assign) + and isinstance(node.value, ast.Call) + and isinstance(node.value.func, ast.Name) + and node.value.func.id == "TypeAliasType" + ): + continue + value = node.value.args[1] + if any( + isinstance(part, ast.Constant) and isinstance(part.value, str) and part.value in schema["$defs"] + for part in ast.walk(value) + ): + continue + original = ast.get_source_segment(source, node.value) + replacement = ast.get_source_segment(source, value) + assert original is not None and replacement is not None + source = source.replace(original, f"({replacement})", 1) + if "= TypeAliasType(" not in source: + source = source.replace("from typing_extensions import TypeAliasType\n", "") # Codegen appends `| None` to forward refs of nullable models, which is a # runtime TypeError on a string ref and redundant since `JSONValue` includes None. source = source.replace('"JSONValue" | None', '"JSONValue"') @@ -256,8 +282,7 @@ def build(entry: dict[str, str]) -> str: source = source.replace("](/", "](https://modelcontextprotocol.io/") source = allow_open_class_extras(source, OPEN_CLASSES[version]) if epilogue := EPILOGUES.get(version, ""): - # Insert before the trailing model_rebuild() block: pyright's evaluation - # order for the recursive RootModel block is sensitive to placement. + # Resolve aliases before rebuilding models with forward references. match = re.search(r"^\w+\.model_rebuild\(\)$", source, flags=re.MULTILINE) cut = match.start() if match else len(source) source = f"{source[:cut]}{epilogue}\n\n{source[cut:]}" diff --git a/src/mcp-types/mcp_types/_v2025_11_25/__init__.py b/src/mcp-types/mcp_types/_v2025_11_25/__init__.py index b5f5b967..5ffd15f0 100644 --- a/src/mcp-types/mcp_types/_v2025_11_25/__init__.py +++ b/src/mcp-types/mcp_types/_v2025_11_25/__init__.py @@ -9,7 +9,7 @@ from __future__ import annotations from typing import Annotated, Any, Literal from mcp_types._wire_base import WireModel -from pydantic import ConfigDict, Field, RootModel +from pydantic import ConfigDict, Field class BaseMetadata(WireModel): @@ -285,11 +285,10 @@ class CompleteResult(WireModel): completion: Completion -class Cursor(RootModel[str]): - root: str - """ - An opaque token used to represent a cursor for pagination. - """ +Cursor = str +""" +An opaque token used to represent a cursor for pagination. +""" class RequestedSchema(WireModel): @@ -556,27 +555,13 @@ class LegacyTitledEnumSchema(WireModel): type: Literal["string"] -class LoggingLevel( - RootModel[ - Literal[ - "alert", - "critical", - "debug", - "emergency", - "error", - "info", - "notice", - "warning", - ] - ] -): - root: Literal["alert", "critical", "debug", "emergency", "error", "info", "notice", "warning"] - """ - The severity of a log message. +LoggingLevel = Literal["alert", "critical", "debug", "emergency", "error", "info", "notice", "warning"] +""" +The severity of a log message. - These map to syslog message severities, as specified in RFC-5424: - https://datatracker.ietf.org/doc/html/rfc5424#section-6.2.1 - """ +These map to syslog message severities, as specified in RFC-5424: +https://datatracker.ietf.org/doc/html/rfc5424#section-6.2.1 +""" class LoggingMessageNotificationParams(WireModel): @@ -723,11 +708,10 @@ class PaginatedResult(WireModel): """ -class ProgressToken(RootModel[str | int]): - root: str | int - """ - A progress token, used to associate progress notifications with the original request. - """ +ProgressToken = str | int +""" +A progress token, used to associate progress notifications with the original request. +""" class PromptArgument(WireModel): @@ -853,11 +837,10 @@ class Request(WireModel): params: dict[str, Any] | None = None -class RequestId(RootModel[str | int]): - root: str | int - """ - A uniquely identifying ID for a request in JSON-RPC. - """ +RequestId = str | int +""" +A uniquely identifying ID for a request in JSON-RPC. +""" class RequestParams(WireModel): @@ -970,11 +953,10 @@ class Result(WireModel): """ -class Role(RootModel[Literal["assistant", "user"]]): - root: Literal["assistant", "user"] - """ - The sender or recipient of messages and data in a conversation. - """ +Role = Literal["assistant", "user"] +""" +The sender or recipient of messages and data in a conversation. +""" class Root(WireModel): @@ -1216,11 +1198,10 @@ class TaskMetadata(WireModel): """ -class TaskStatus(RootModel[Literal["cancelled", "completed", "failed", "input_required", "working"]]): - root: Literal["cancelled", "completed", "failed", "input_required", "working"] - """ - The status of a task. - """ +TaskStatus = Literal["cancelled", "completed", "failed", "input_required", "working"] +""" +The status of a task. +""" class TextResourceContents(WireModel): @@ -1867,26 +1848,16 @@ class EmbeddedResource(WireModel): type: Literal["resource"] -class EmptyResult(RootModel[Result]): - root: Result +EmptyResult = Result -class EnumSchema( - RootModel[ - UntitledSingleSelectEnumSchema - | TitledSingleSelectEnumSchema - | UntitledMultiSelectEnumSchema - | TitledMultiSelectEnumSchema - | LegacyTitledEnumSchema - ] -): - root: ( - UntitledSingleSelectEnumSchema - | TitledSingleSelectEnumSchema - | UntitledMultiSelectEnumSchema - | TitledMultiSelectEnumSchema - | LegacyTitledEnumSchema - ) +EnumSchema = ( + UntitledSingleSelectEnumSchema + | TitledSingleSelectEnumSchema + | UntitledMultiSelectEnumSchema + | TitledMultiSelectEnumSchema + | LegacyTitledEnumSchema +) class GetPromptRequestParams(WireModel): @@ -2115,8 +2086,7 @@ class LoggingMessageNotification(WireModel): params: LoggingMessageNotificationParams -class MultiSelectEnumSchema(RootModel[UntitledMultiSelectEnumSchema | TitledMultiSelectEnumSchema]): - root: UntitledMultiSelectEnumSchema | TitledMultiSelectEnumSchema +MultiSelectEnumSchema = UntitledMultiSelectEnumSchema | TitledMultiSelectEnumSchema class PaginatedRequestParams(WireModel): @@ -2152,32 +2122,20 @@ class PingRequest(WireModel): params: RequestParams | None = None -class PrimitiveSchemaDefinition( - RootModel[ - StringSchema - | NumberSchema - | BooleanSchema - | UntitledSingleSelectEnumSchema - | TitledSingleSelectEnumSchema - | UntitledMultiSelectEnumSchema - | TitledMultiSelectEnumSchema - | LegacyTitledEnumSchema - ] -): - root: ( - StringSchema - | NumberSchema - | BooleanSchema - | UntitledSingleSelectEnumSchema - | TitledSingleSelectEnumSchema - | UntitledMultiSelectEnumSchema - | TitledMultiSelectEnumSchema - | LegacyTitledEnumSchema - ) - """ - Restricted schema definitions that only allow primitive types - without nested objects or arrays. - """ +PrimitiveSchemaDefinition = ( + StringSchema + | NumberSchema + | BooleanSchema + | UntitledSingleSelectEnumSchema + | TitledSingleSelectEnumSchema + | UntitledMultiSelectEnumSchema + | TitledMultiSelectEnumSchema + | LegacyTitledEnumSchema +) +""" +Restricted schema definitions that only allow primitive types +without nested objects or arrays. +""" class ProgressNotificationParams(WireModel): @@ -2499,8 +2457,7 @@ class SetLevelRequest(WireModel): params: SetLevelRequestParams -class SingleSelectEnumSchema(RootModel[UntitledSingleSelectEnumSchema | TitledSingleSelectEnumSchema]): - root: UntitledSingleSelectEnumSchema | TitledSingleSelectEnumSchema +SingleSelectEnumSchema = UntitledSingleSelectEnumSchema | TitledSingleSelectEnumSchema class SubscribeRequest(WireModel): @@ -2793,8 +2750,7 @@ class CompleteRequest(WireModel): params: CompleteRequestParams -class ContentBlock(RootModel[TextContent | ImageContent | AudioContent | ResourceLink | EmbeddedResource]): - root: TextContent | ImageContent | AudioContent | ResourceLink | EmbeddedResource +ContentBlock = TextContent | ImageContent | AudioContent | ResourceLink | EmbeddedResource class CreateTaskResult(WireModel): @@ -2812,11 +2768,10 @@ class CreateTaskResult(WireModel): task: Task -class ElicitRequestParams(RootModel[ElicitRequestURLParams | ElicitRequestFormParams]): - root: ElicitRequestURLParams | ElicitRequestFormParams - """ - The parameters for a request to elicit additional information from the user via the client. - """ +ElicitRequestParams = ElicitRequestURLParams | ElicitRequestFormParams +""" +The parameters for a request to elicit additional information from the user via the client. +""" class GetPromptRequest(WireModel): @@ -2857,18 +2812,16 @@ class InitializeRequest(WireModel): params: InitializeRequestParams -class JSONRPCMessage(RootModel[JSONRPCRequest | JSONRPCNotification | JSONRPCResultResponse | JSONRPCErrorResponse]): - root: JSONRPCRequest | JSONRPCNotification | JSONRPCResultResponse | JSONRPCErrorResponse - """ - Refers to any valid JSON-RPC object that can be decoded off the wire, or encoded to be sent. - """ +JSONRPCMessage = JSONRPCRequest | JSONRPCNotification | JSONRPCResultResponse | JSONRPCErrorResponse +""" +Refers to any valid JSON-RPC object that can be decoded off the wire, or encoded to be sent. +""" -class JSONRPCResponse(RootModel[JSONRPCResultResponse | JSONRPCErrorResponse]): - root: JSONRPCResultResponse | JSONRPCErrorResponse - """ - A response to a request, containing either the result or error. - """ +JSONRPCResponse = JSONRPCResultResponse | JSONRPCErrorResponse +""" +A response to a request, containing either the result or error. +""" class ListPromptsRequest(WireModel): @@ -3173,64 +3126,34 @@ class CallToolResult(WireModel): """ -class ClientNotification( - RootModel[ - CancelledNotification - | InitializedNotification - | ProgressNotification - | TaskStatusNotification - | RootsListChangedNotification - ] -): - root: ( - CancelledNotification - | InitializedNotification - | ProgressNotification - | TaskStatusNotification - | RootsListChangedNotification - ) +ClientNotification = ( + CancelledNotification + | InitializedNotification + | ProgressNotification + | TaskStatusNotification + | RootsListChangedNotification +) -class ClientRequest( - RootModel[ - InitializeRequest - | PingRequest - | ListResourcesRequest - | ListResourceTemplatesRequest - | ReadResourceRequest - | SubscribeRequest - | UnsubscribeRequest - | ListPromptsRequest - | GetPromptRequest - | ListToolsRequest - | CallToolRequest - | GetTaskRequest - | GetTaskPayloadRequest - | CancelTaskRequest - | ListTasksRequest - | SetLevelRequest - | CompleteRequest - ] -): - root: ( - InitializeRequest - | PingRequest - | ListResourcesRequest - | ListResourceTemplatesRequest - | ReadResourceRequest - | SubscribeRequest - | UnsubscribeRequest - | ListPromptsRequest - | GetPromptRequest - | ListToolsRequest - | CallToolRequest - | GetTaskRequest - | GetTaskPayloadRequest - | CancelTaskRequest - | ListTasksRequest - | SetLevelRequest - | CompleteRequest - ) +ClientRequest = ( + InitializeRequest + | PingRequest + | ListResourcesRequest + | ListResourceTemplatesRequest + | ReadResourceRequest + | SubscribeRequest + | UnsubscribeRequest + | ListPromptsRequest + | GetPromptRequest + | ListToolsRequest + | CallToolRequest + | GetTaskRequest + | GetTaskPayloadRequest + | CancelTaskRequest + | ListTasksRequest + | SetLevelRequest + | CompleteRequest +) class ElicitRequest(WireModel): @@ -3266,72 +3189,38 @@ class GetPromptResult(WireModel): messages: list[PromptMessage] -class SamplingMessageContentBlock( - RootModel[TextContent | ImageContent | AudioContent | ToolUseContent | ToolResultContent] -): - root: TextContent | ImageContent | AudioContent | ToolUseContent | ToolResultContent +SamplingMessageContentBlock = TextContent | ImageContent | AudioContent | ToolUseContent | ToolResultContent -class ServerNotification( - RootModel[ - CancelledNotification - | ProgressNotification - | ResourceListChangedNotification - | ResourceUpdatedNotification - | PromptListChangedNotification - | ToolListChangedNotification - | TaskStatusNotification - | LoggingMessageNotification - | ElicitationCompleteNotification - ] -): - root: ( - CancelledNotification - | ProgressNotification - | ResourceListChangedNotification - | ResourceUpdatedNotification - | PromptListChangedNotification - | ToolListChangedNotification - | TaskStatusNotification - | LoggingMessageNotification - | ElicitationCompleteNotification - ) +ServerNotification = ( + CancelledNotification + | ProgressNotification + | ResourceListChangedNotification + | ResourceUpdatedNotification + | PromptListChangedNotification + | ToolListChangedNotification + | TaskStatusNotification + | LoggingMessageNotification + | ElicitationCompleteNotification +) -class ServerResult( - RootModel[ - Result - | InitializeResult - | ListResourcesResult - | ListResourceTemplatesResult - | ReadResourceResult - | ListPromptsResult - | GetPromptResult - | ListToolsResult - | CallToolResult - | GetTaskResult - | GetTaskPayloadResult - | CancelTaskResult - | ListTasksResult - | CompleteResult - ] -): - root: ( - Result - | InitializeResult - | ListResourcesResult - | ListResourceTemplatesResult - | ReadResourceResult - | ListPromptsResult - | GetPromptResult - | ListToolsResult - | CallToolResult - | GetTaskResult - | GetTaskPayloadResult - | CancelTaskResult - | ListTasksResult - | CompleteResult - ) +ServerResult = ( + Result + | InitializeResult + | ListResourcesResult + | ListResourceTemplatesResult + | ReadResourceResult + | ListPromptsResult + | GetPromptResult + | ListToolsResult + | CallToolResult + | GetTaskResult + | GetTaskPayloadResult + | CancelTaskResult + | ListTasksResult + | CompleteResult +) class CreateMessageResult(WireModel): @@ -3398,28 +3287,16 @@ class SamplingMessage(WireModel): role: Role -class ClientResult( - RootModel[ - Result - | GetTaskResult - | GetTaskPayloadResult - | CancelTaskResult - | ListTasksResult - | CreateMessageResult - | ListRootsResult - | ElicitResult - ] -): - root: ( - Result - | GetTaskResult - | GetTaskPayloadResult - | CancelTaskResult - | ListTasksResult - | CreateMessageResult - | ListRootsResult - | ElicitResult - ) +ClientResult = ( + Result + | GetTaskResult + | GetTaskPayloadResult + | CancelTaskResult + | ListTasksResult + | CreateMessageResult + | ListRootsResult + | ElicitResult +) class CreateMessageRequestParams(WireModel): @@ -3502,25 +3379,13 @@ class CreateMessageRequest(WireModel): params: CreateMessageRequestParams -class ServerRequest( - RootModel[ - PingRequest - | GetTaskRequest - | GetTaskPayloadRequest - | CancelTaskRequest - | ListTasksRequest - | CreateMessageRequest - | ListRootsRequest - | ElicitRequest - ] -): - root: ( - PingRequest - | GetTaskRequest - | GetTaskPayloadRequest - | CancelTaskRequest - | ListTasksRequest - | CreateMessageRequest - | ListRootsRequest - | ElicitRequest - ) +ServerRequest = ( + PingRequest + | GetTaskRequest + | GetTaskPayloadRequest + | CancelTaskRequest + | ListTasksRequest + | CreateMessageRequest + | ListRootsRequest + | ElicitRequest +) diff --git a/src/mcp-types/mcp_types/_v2026_07_28/__init__.py b/src/mcp-types/mcp_types/_v2026_07_28/__init__.py index fb168b30..869700f2 100644 --- a/src/mcp-types/mcp_types/_v2026_07_28/__init__.py +++ b/src/mcp-types/mcp_types/_v2026_07_28/__init__.py @@ -9,7 +9,8 @@ from __future__ import annotations from typing import Annotated, Any, Literal, Union from mcp_types._wire_base import WireModel -from pydantic import ConfigDict, Field, RootModel +from pydantic import ConfigDict, Field +from typing_extensions import TypeAliasType class BaseMetadata(WireModel): @@ -95,11 +96,10 @@ class Completion(WireModel): """ -class Cursor(RootModel[str]): - root: str - """ - An opaque token used to represent a cursor for pagination. - """ +Cursor = str +""" +An opaque token used to represent a cursor for pagination. +""" class RequestedSchema(WireModel): @@ -415,6 +415,12 @@ class JSONRPCNotification(WireModel): params: dict[str, Any] | None = None +JSONValue = TypeAliasType( + "JSONValue", + Union[dict[str, "JSONValue"], list["JSONValue"], str | int | float | bool | None], +) + + class LegacyTitledEnumSchema(WireModel): """ Use {@link TitledSingleSelectEnumSchema} instead. @@ -436,27 +442,13 @@ class LegacyTitledEnumSchema(WireModel): type: Literal["string"] -class LoggingLevel( - RootModel[ - Literal[ - "alert", - "critical", - "debug", - "emergency", - "error", - "info", - "notice", - "warning", - ] - ] -): - root: Literal["alert", "critical", "debug", "emergency", "error", "info", "notice", "warning"] - """ - The severity of a log message. +LoggingLevel = Literal["alert", "critical", "debug", "emergency", "error", "info", "notice", "warning"] +""" +The severity of a log message. - These map to syslog message severities, as specified in RFC-5424: - https://datatracker.ietf.org/doc/html/rfc5424#section-6.2.1 - """ +These map to syslog message severities, as specified in RFC-5424: +https://datatracker.ietf.org/doc/html/rfc5424#section-6.2.1 +""" class MetaObject(WireModel): @@ -624,11 +616,10 @@ class ParseError(WireModel): """ -class ProgressToken(RootModel[str | int]): - root: str | int - """ - A progress token, used to associate progress notifications with the original request. - """ +ProgressToken = str | int +""" +A progress token, used to associate progress notifications with the original request. +""" class PromptArgument(WireModel): @@ -694,11 +685,10 @@ class Request(WireModel): params: dict[str, Any] | None = None -class RequestId(RootModel[str | int]): - root: str | int - """ - A uniquely identifying ID for a request in JSON-RPC. - """ +RequestId = str | int +""" +A uniquely identifying ID for a request in JSON-RPC. +""" class ResourceContents(WireModel): @@ -759,22 +749,20 @@ class ResultMetaObject(WireModel): """ -class ResultType(RootModel[str]): - root: str - """ - Indicates the type of a {@link Result} object, allowing the client to - determine how to parse the response. +ResultType = str +""" +Indicates the type of a {@link Result} object, allowing the client to +determine how to parse the response. - complete - the request completed successfully and the result contains the final content. - input_required - the request requires additional input and the result contains an {@link InputRequiredResult} object with instructions for the client to provide additional input before retrying the original request. - """ +complete - the request completed successfully and the result contains the final content. +input_required - the request requires additional input and the result contains an {@link InputRequiredResult} object with instructions for the client to provide additional input before retrying the original request. +""" -class Role(RootModel[Literal["assistant", "user"]]): - root: Literal["assistant", "user"] - """ - The sender or recipient of messages and data in a conversation. - """ +Role = Literal["assistant", "user"] +""" +The sender or recipient of messages and data in a conversation. +""" class Root(WireModel): @@ -1468,11 +1456,10 @@ class CompleteResultResponse(WireModel): result: CompleteResult -class ElicitRequestParams(RootModel[ElicitRequestFormParams | ElicitRequestURLParams]): - root: ElicitRequestFormParams | ElicitRequestURLParams - """ - The parameters for a request to elicit additional information from the user via the client. - """ +ElicitRequestParams = ElicitRequestFormParams | ElicitRequestURLParams +""" +The parameters for a request to elicit additional information from the user via the client. +""" class EmbeddedResource(WireModel): @@ -1495,22 +1482,13 @@ class EmbeddedResource(WireModel): type: Literal["resource"] -class EnumSchema( - RootModel[ - UntitledSingleSelectEnumSchema - | TitledSingleSelectEnumSchema - | UntitledMultiSelectEnumSchema - | TitledMultiSelectEnumSchema - | LegacyTitledEnumSchema - ] -): - root: ( - UntitledSingleSelectEnumSchema - | TitledSingleSelectEnumSchema - | UntitledMultiSelectEnumSchema - | TitledMultiSelectEnumSchema - | LegacyTitledEnumSchema - ) +EnumSchema = ( + UntitledSingleSelectEnumSchema + | TitledSingleSelectEnumSchema + | UntitledMultiSelectEnumSchema + | TitledMultiSelectEnumSchema + | LegacyTitledEnumSchema +) class HeaderMismatchError(WireModel): @@ -1553,6 +1531,12 @@ class ImageContent(WireModel): type: Literal["image"] +JSONArray = list[JSONValue] + + +JSONObject = dict[str, JSONValue] + + class JSONRPCErrorResponse(WireModel): """ A response to a request that indicates an error occurred. @@ -1618,8 +1602,7 @@ class ListRootsResult(WireModel): roots: list[Root] -class MultiSelectEnumSchema(RootModel[UntitledMultiSelectEnumSchema | TitledMultiSelectEnumSchema]): - root: UntitledMultiSelectEnumSchema | TitledMultiSelectEnumSchema +MultiSelectEnumSchema = UntitledMultiSelectEnumSchema | TitledMultiSelectEnumSchema class NotificationMetaObject(WireModel): @@ -1680,32 +1663,20 @@ class PaginatedResult(WireModel): """ -class PrimitiveSchemaDefinition( - RootModel[ - StringSchema - | NumberSchema - | BooleanSchema - | UntitledSingleSelectEnumSchema - | TitledSingleSelectEnumSchema - | UntitledMultiSelectEnumSchema - | TitledMultiSelectEnumSchema - | LegacyTitledEnumSchema - ] -): - root: ( - StringSchema - | NumberSchema - | BooleanSchema - | UntitledSingleSelectEnumSchema - | TitledSingleSelectEnumSchema - | UntitledMultiSelectEnumSchema - | TitledMultiSelectEnumSchema - | LegacyTitledEnumSchema - ) - """ - Restricted schema definitions that only allow primitive types - without nested objects or arrays. - """ +PrimitiveSchemaDefinition = ( + StringSchema + | NumberSchema + | BooleanSchema + | UntitledSingleSelectEnumSchema + | TitledSingleSelectEnumSchema + | UntitledMultiSelectEnumSchema + | TitledMultiSelectEnumSchema + | LegacyTitledEnumSchema +) +""" +Restricted schema definitions that only allow primitive types +without nested objects or arrays. +""" class ProgressNotificationParams(WireModel): @@ -2064,8 +2035,50 @@ class Result(WireModel): """ -class SingleSelectEnumSchema(RootModel[UntitledSingleSelectEnumSchema | TitledSingleSelectEnumSchema]): - root: UntitledSingleSelectEnumSchema | TitledSingleSelectEnumSchema +class ServerCapabilities(WireModel): + """ + Capabilities that a server may support. Known capabilities are defined here, in this schema, but this is not a closed set: any server can define its own, additional capabilities. + """ + + model_config = ConfigDict( + extra="ignore", + ) + completions: JSONObject | None = None + """ + Present if the server supports argument autocompletion suggestions. + """ + experimental: dict[str, JSONObject] | None = None + """ + Experimental, non-standard capabilities that the server supports. + """ + extensions: dict[str, JSONObject] | None = None + """ + Optional MCP extensions that the server supports. Keys are extension identifiers + (e.g., "io.modelcontextprotocol/tasks"), and values are per-extension settings + objects. An empty object indicates support with no settings. + + Keys MUST follow the {@link MetaObject`_meta` key naming rules}, with a + mandatory prefix. + """ + logging: JSONObject | None = None + """ + Present if the server supports sending log messages to the client. + """ + prompts: Prompts | None = None + """ + Present if the server offers any prompt templates. + """ + resources: Resources | None = None + """ + Present if the server offers any resources to read. + """ + tools: Tools | None = None + """ + Present if the server offers any tools to call. + """ + + +SingleSelectEnumSchema = UntitledSingleSelectEnumSchema | TitledSingleSelectEnumSchema class SubscriptionsAcknowledgedNotificationParams(WireModel): @@ -2237,6 +2250,72 @@ class CancelledNotificationParams(WireModel): """ +class Elicitation(WireModel): + """ + Present if the client supports elicitation from the server. + """ + + model_config = ConfigDict( + extra="ignore", + ) + form: JSONObject | None = None + url: JSONObject | None = None + + +class Sampling(WireModel): + """ + Present if the client supports sampling from an LLM. + """ + + model_config = ConfigDict( + extra="ignore", + ) + context: JSONObject | None = None + """ + Whether the client supports context inclusion via `includeContext` parameter. + If not declared, servers SHOULD only use `includeContext: "none"` (or omit it). + """ + tools: JSONObject | None = None + """ + Whether the client supports tool use via `tools` and `toolChoice` parameters. + """ + + +class ClientCapabilities(WireModel): + """ + Capabilities a client may support. Known capabilities are defined here, in this schema, but this is not a closed set: any client can define its own, additional capabilities. + """ + + model_config = ConfigDict( + extra="ignore", + ) + elicitation: Elicitation | None = None + """ + Present if the client supports elicitation from the server. + """ + experimental: dict[str, JSONObject] | None = None + """ + Experimental, non-standard capabilities that the client supports. + """ + extensions: dict[str, JSONObject] | None = None + """ + Optional MCP extensions that the client supports. Keys are extension identifiers + (e.g., "io.modelcontextprotocol/oauth-client-credentials"), and values are + per-extension settings objects. An empty object indicates support with no settings. + + Keys MUST follow the {@link MetaObject`_meta` key naming rules}, with a + mandatory prefix. + """ + roots: dict[str, Any] | None = None + """ + Present if the client supports listing roots. + """ + sampling: Sampling | None = None + """ + Present if the client supports sampling from an LLM. + """ + + class ClientNotification(WireModel): """ This notification is sent by the client to indicate that it is cancelling a request it previously issued. @@ -2256,15 +2335,89 @@ class ClientNotification(WireModel): params: CancelledNotificationParams -class ClientResult(RootModel[Result]): - root: Result +ClientResult = Result +""" +Common result fields. +""" + + +ContentBlock = TextContent | ImageContent | AudioContent | ResourceLink | EmbeddedResource + + +class DiscoverResult(WireModel): """ - Common result fields. + The result returned by the server for a {@link DiscoverRequestserver/discover} request. + """ + + model_config = ConfigDict( + extra="ignore", + ) + meta: Annotated[ResultMetaObject | None, Field(alias="_meta")] = None + cache_scope: Annotated[Literal["private", "public"], Field(alias="cacheScope")] + """ + Indicates the intended scope of the cached response, analogous to HTTP + `Cache-Control: public` vs `Cache-Control: private`. + + - `"public"`: The response does not contain user-specific data. Any + client or intermediary (e.g., shared gateway, caching proxy) MAY cache + the response and serve it across authorization contexts. + - `"private"`: The response MAY be cached and reused only within the + same authorization context. Caches MUST NOT be shared across + authorization contexts (e.g., a different access token requires a + different cache). + """ + capabilities: ServerCapabilities + """ + The capabilities of the server. + """ + instructions: str | None = None + """ + Natural-language guidance describing the server and its features. + + This can be used by clients to improve an LLM's understanding of + available tools (e.g., by including it in a system prompt). It should + focus on information that helps the model use the server effectively + and should not duplicate information already in tool descriptions. + """ + result_type: Annotated[str, Field(alias="resultType")] + """ + Indicates the type of the result, which allows the client to determine + how to parse the result object. + + Servers implementing this protocol version MUST include this field. + For backward compatibility, when a client receives a result from a + server implementing an earlier protocol version (which does not include + `resultType`), the client MUST treat the absent field as `"complete"`. + """ + supported_versions: Annotated[list[str], Field(alias="supportedVersions")] + """ + MCP Protocol Versions this server supports. The client should choose a + version from this list for use in subsequent requests. + """ + ttl_ms: Annotated[int, Field(alias="ttlMs", ge=0)] + """ + A hint from the server indicating how long (in milliseconds) the + client MAY cache this response before re-fetching. Semantics are + analogous to HTTP Cache-Control max-age. + + - If 0, The response SHOULD be considered immediately stale, + The client MAY re-fetch every time the result is needed. + - If positive, the client SHOULD consider the result fresh for this many + milliseconds after receiving the response. """ -class ContentBlock(RootModel[TextContent | ImageContent | AudioContent | ResourceLink | EmbeddedResource]): - root: TextContent | ImageContent | AudioContent | ResourceLink | EmbeddedResource +class DiscoverResultResponse(WireModel): + """ + A successful response from the server for a {@link DiscoverRequestserver/discover} request. + """ + + model_config = ConfigDict( + extra="ignore", + ) + id: RequestId + jsonrpc: Literal["2.0"] + result: DiscoverResult class ElicitRequest(WireModel): @@ -2279,11 +2432,10 @@ class ElicitRequest(WireModel): params: ElicitRequestParams -class EmptyResult(RootModel[Result]): - root: Result - """ - Common result fields. - """ +EmptyResult = Result +""" +Common result fields. +""" class JSONRPCResultResponse(WireModel): @@ -2578,6 +2730,49 @@ class LoggingMessageNotificationParams(WireModel): """ +class Data(WireModel): + """ + Additional information about the error. The value of this member is defined by the sender (e.g. detailed error information, nested errors etc.). + """ + + model_config = ConfigDict( + extra="ignore", + ) + required_capabilities: Annotated[ClientCapabilities, Field(alias="requiredCapabilities")] + """ + The capabilities the server requires from the client to process this request. + """ + + +class Error2(Error): + model_config = ConfigDict( + extra="ignore", + ) + code: Literal[-32021] + """ + The error type that occurred. + """ + data: Data + """ + Additional information about the error. The value of this member is defined by the sender (e.g. detailed error information, nested errors etc.). + """ + + +class MissingRequiredClientCapabilityError(WireModel): + """ + Returned when processing a request requires a capability the client did not + declare in `clientCapabilities`. For HTTP, the response status code MUST be + `400 Bad Request`. + """ + + model_config = ConfigDict( + extra="ignore", + ) + error: Error2 + id: RequestId | None = None + jsonrpc: Literal["2.0"] + + class ProgressNotification(WireModel): """ An out-of-band notification used to inform the receiver of a progress update for a long-running request. @@ -2606,6 +2801,91 @@ class PromptMessage(WireModel): role: Role +class RequestMetaObject(WireModel): + """ + Extends {@link MetaObject} with additional request-specific fields. All key naming rules from `MetaObject` apply. + """ + + model_config = ConfigDict( + extra="allow", + ) + io_modelcontextprotocol_client_capabilities: Annotated[ + ClientCapabilities, Field(alias="io.modelcontextprotocol/clientCapabilities") + ] + """ + The client's capabilities for this specific request. Required. + + Capabilities are declared per-request rather than once at initialization; + an empty object means the client supports no optional capabilities. + Servers MUST NOT infer capabilities from prior requests. + """ + io_modelcontextprotocol_client_info: Annotated[ + Implementation | None, Field(alias="io.modelcontextprotocol/clientInfo") + ] = None + """ + Identifies the client software making the request. Clients SHOULD + include this field on every request unless specifically configured not + to do so. + + The {@link Implementation} schema requires `name` and `version`; other + fields are optional. + + The value is self-reported by the client and is not verified by the + protocol. It is intended for display, logging, and debugging. Servers + SHOULD NOT use it to change their behavior, and SHOULD NOT rely on it for + security decisions. + """ + io_modelcontextprotocol_log_level: Annotated[ + LoggingLevel | None, Field(alias="io.modelcontextprotocol/logLevel") + ] = None + """ + The desired log level for this request. Optional. + + If absent, the server MUST NOT send any {@link LoggingMessageNotificationnotifications/message} + notifications for this request. The client opts in to log messages by + explicitly setting a level. Replaces the former `logging/setLevel` RPC. + """ + io_modelcontextprotocol_protocol_version: Annotated[str, Field(alias="io.modelcontextprotocol/protocolVersion")] + """ + The MCP Protocol Version being used for this request. Required. + + For the HTTP transport, this value MUST match the `MCP-Protocol-Version` + header; otherwise the server MUST return a `400 Bad Request`. If the + server does not support the requested version, it MUST return an + {@link UnsupportedProtocolVersionError}. + """ + progress_token: Annotated[ProgressToken | None, Field(alias="progressToken")] = None + """ + If specified, the caller is requesting out-of-band progress notifications for this request (as represented by {@link ProgressNotificationnotifications/progress}). The value of this parameter is an opaque token that will be attached to any subsequent notifications. The receiver is not obligated to provide these notifications. + """ + + +class RequestParams(WireModel): + """ + Common params for any request. + """ + + model_config = ConfigDict( + extra="ignore", + ) + meta: Annotated[RequestMetaObject, Field(alias="_meta")] + + +class ResourceRequestParams(WireModel): + """ + Common params for resource-related requests. + """ + + model_config = ConfigDict( + extra="ignore", + ) + meta: Annotated[RequestMetaObject, Field(alias="_meta")] + uri: str + """ + The URI of the resource. The URI can use any protocol; it is up to the server how to interpret it. + """ + + class ResourceUpdatedNotification(WireModel): """ A notification from the server to the client, informing it that a resource has changed and may need to be read again. This is only sent for resources the client opted in to via the `resourceSubscriptions` field of a {@link SubscriptionsListenRequestsubscriptions/listen} request. @@ -2641,6 +2921,23 @@ class SubscriptionsAcknowledgedNotification(WireModel): params: SubscriptionsAcknowledgedNotificationParams +class SubscriptionsListenRequestParams(WireModel): + """ + Parameters for a {@link SubscriptionsListenRequestsubscriptions/listen} request. + """ + + model_config = ConfigDict( + extra="ignore", + ) + meta: Annotated[RequestMetaObject, Field(alias="_meta")] + notifications: SubscriptionFilter + """ + The notifications the client opts in to on this stream. The server + **MUST NOT** send notification types the client has not explicitly + requested. + """ + + class ToolResultContent(WireModel): """ The result of a tool use, provided by the user back to the assistant. @@ -2750,6 +3047,43 @@ class CancelledNotification(WireModel): params: CancelledNotificationParams +class CompleteRequestParams(WireModel): + """ + Parameters for a `completion/complete` request. + """ + + model_config = ConfigDict( + extra="ignore", + ) + meta: Annotated[RequestMetaObject, Field(alias="_meta")] + argument: Argument + """ + The argument's information + """ + context: Context | None = None + """ + Additional, optional context for completions + """ + ref: PromptReference | ResourceTemplateReference + + +class DiscoverRequest(WireModel): + """ + A request from the client asking the server to advertise its supported + protocol versions, capabilities, and other metadata. Servers **MUST** + implement `server/discover`. Clients **MAY** call it but are not required + to — version negotiation can also happen inline via per-request `_meta`. + """ + + model_config = ConfigDict( + extra="ignore", + ) + id: RequestId + jsonrpc: Literal["2.0"] + method: Literal["server/discover"] + params: RequestParams + + class GetPromptResult(WireModel): """ The result returned by the server for a {@link GetPromptRequestprompts/get} request. @@ -2776,18 +3110,16 @@ class GetPromptResult(WireModel): """ -class JSONRPCMessage(RootModel[JSONRPCRequest | JSONRPCNotification | JSONRPCResultResponse | JSONRPCErrorResponse]): - root: JSONRPCRequest | JSONRPCNotification | JSONRPCResultResponse | JSONRPCErrorResponse - """ - Refers to any valid JSON-RPC object that can be decoded off the wire, or encoded to be sent. - """ +JSONRPCMessage = JSONRPCRequest | JSONRPCNotification | JSONRPCResultResponse | JSONRPCErrorResponse +""" +Refers to any valid JSON-RPC object that can be decoded off the wire, or encoded to be sent. +""" -class JSONRPCResponse(RootModel[JSONRPCResultResponse | JSONRPCErrorResponse]): - root: JSONRPCResultResponse | JSONRPCErrorResponse - """ - A response to a request, containing either the result or error. - """ +JSONRPCResponse = JSONRPCResultResponse | JSONRPCErrorResponse +""" +A response to a request, containing either the result or error. +""" class LoggingMessageNotification(WireModel): @@ -2803,34 +3135,65 @@ class LoggingMessageNotification(WireModel): params: LoggingMessageNotificationParams -class SamplingMessageContentBlock( - RootModel[TextContent | ImageContent | AudioContent | ToolUseContent | ToolResultContent] -): - root: TextContent | ImageContent | AudioContent | ToolUseContent | ToolResultContent +class PaginatedRequestParams(WireModel): + """ + Common params for paginated requests. + """ - -class ServerNotification( - RootModel[ - CancelledNotification - | ProgressNotification - | ResourceListChangedNotification - | SubscriptionsAcknowledgedNotification - | ResourceUpdatedNotification - | PromptListChangedNotification - | ToolListChangedNotification - | LoggingMessageNotification - ] -): - root: ( - CancelledNotification - | ProgressNotification - | ResourceListChangedNotification - | SubscriptionsAcknowledgedNotification - | ResourceUpdatedNotification - | PromptListChangedNotification - | ToolListChangedNotification - | LoggingMessageNotification + model_config = ConfigDict( + extra="ignore", ) + meta: Annotated[RequestMetaObject, Field(alias="_meta")] + cursor: str | None = None + """ + An opaque token representing the current pagination position. + If provided, the server should return results starting after this cursor. + """ + + +SamplingMessageContentBlock = TextContent | ImageContent | AudioContent | ToolUseContent | ToolResultContent + + +ServerNotification = ( + CancelledNotification + | ProgressNotification + | ResourceListChangedNotification + | SubscriptionsAcknowledgedNotification + | ResourceUpdatedNotification + | PromptListChangedNotification + | ToolListChangedNotification + | LoggingMessageNotification +) + + +class SubscriptionsListenRequest(WireModel): + """ + Sent from the client to open a long-lived channel for receiving notifications + outside the context of a specific request. Replaces the previous HTTP GET + endpoint and ensures consistent behavior between HTTP and STDIO. + """ + + model_config = ConfigDict( + extra="ignore", + ) + id: RequestId + jsonrpc: Literal["2.0"] + method: Literal["subscriptions/listen"] + params: SubscriptionsListenRequestParams + + +class CompleteRequest(WireModel): + """ + A request from the client to the server, to ask for completion options. + """ + + model_config = ConfigDict( + extra="ignore", + ) + id: RequestId + jsonrpc: Literal["2.0"] + method: Literal["completion/complete"] + params: CompleteRequestParams class CreateMessageResult(WireModel): @@ -2871,428 +3234,15 @@ class CreateMessageResult(WireModel): """ -class InputResponse(RootModel[CreateMessageResult | ListRootsResult | ElicitResult]): - root: CreateMessageResult | ListRootsResult | ElicitResult +InputResponse = CreateMessageResult | ListRootsResult | ElicitResult -class InputResponses(RootModel[dict[str, InputResponse]]): - """ - A map of client responses to server-initiated requests. - Keys correspond to the keys in the {@link InputRequests} map; - values are the client's result for each request. - """ - - root: dict[str, InputResponse] - - -class SamplingMessage(WireModel): - """ - Describes a message issued to or received from an LLM API. - """ - - model_config = ConfigDict( - extra="ignore", - ) - meta: Annotated[MetaObject | None, Field(alias="_meta")] = None - content: ( - TextContent - | ImageContent - | AudioContent - | ToolUseContent - | ToolResultContent - | list[SamplingMessageContentBlock] - ) - role: Role - - -class CallToolRequest(WireModel): - """ - Used by the client to invoke a tool provided by the server. - """ - - model_config = ConfigDict( - extra="ignore", - ) - id: RequestId - jsonrpc: Literal["2.0"] - method: Literal["tools/call"] - params: CallToolRequestParams - - -class CallToolRequestParams(WireModel): - """ - Parameters for a `tools/call` request. - """ - - model_config = ConfigDict( - extra="ignore", - ) - meta: Annotated[RequestMetaObject, Field(alias="_meta")] - arguments: dict[str, Any] | None = None - """ - Arguments to use for the tool call. - """ - input_responses: Annotated[InputResponses | None, Field(alias="inputResponses")] = None - name: str - """ - The name of the tool. - """ - request_state: Annotated[str | None, Field(alias="requestState")] = None - - -class CallToolResultResponse(WireModel): - """ - A successful response from the server for a {@link CallToolRequesttools/call} request. - """ - - model_config = ConfigDict( - extra="ignore", - ) - id: RequestId - jsonrpc: Literal["2.0"] - result: InputRequiredResult | CallToolResult - - -class Elicitation(WireModel): - """ - Present if the client supports elicitation from the server. - """ - - model_config = ConfigDict( - extra="ignore", - ) - form: JSONObject | None = None - url: JSONObject | None = None - - -class Sampling(WireModel): - """ - Present if the client supports sampling from an LLM. - """ - - model_config = ConfigDict( - extra="ignore", - ) - context: JSONObject | None = None - """ - Whether the client supports context inclusion via `includeContext` parameter. - If not declared, servers SHOULD only use `includeContext: "none"` (or omit it). - """ - tools: JSONObject | None = None - """ - Whether the client supports tool use via `tools` and `toolChoice` parameters. - """ - - -class ClientCapabilities(WireModel): - """ - Capabilities a client may support. Known capabilities are defined here, in this schema, but this is not a closed set: any client can define its own, additional capabilities. - """ - - model_config = ConfigDict( - extra="ignore", - ) - elicitation: Elicitation | None = None - """ - Present if the client supports elicitation from the server. - """ - experimental: dict[str, JSONObject] | None = None - """ - Experimental, non-standard capabilities that the client supports. - """ - extensions: dict[str, JSONObject] | None = None - """ - Optional MCP extensions that the client supports. Keys are extension identifiers - (e.g., "io.modelcontextprotocol/oauth-client-credentials"), and values are - per-extension settings objects. An empty object indicates support with no settings. - - Keys MUST follow the {@link MetaObject`_meta` key naming rules}, with a - mandatory prefix. - """ - roots: dict[str, Any] | None = None - """ - Present if the client supports listing roots. - """ - sampling: Sampling | None = None - """ - Present if the client supports sampling from an LLM. - """ - - -class CompleteRequest(WireModel): - """ - A request from the client to the server, to ask for completion options. - """ - - model_config = ConfigDict( - extra="ignore", - ) - id: RequestId - jsonrpc: Literal["2.0"] - method: Literal["completion/complete"] - params: CompleteRequestParams - - -class CompleteRequestParams(WireModel): - """ - Parameters for a `completion/complete` request. - """ - - model_config = ConfigDict( - extra="ignore", - ) - meta: Annotated[RequestMetaObject, Field(alias="_meta")] - argument: Argument - """ - The argument's information - """ - context: Context | None = None - """ - Additional, optional context for completions - """ - ref: PromptReference | ResourceTemplateReference - - -class CreateMessageRequest(WireModel): - """ - A request from the server to sample an LLM via the client. The client has full discretion over which model to select. The client should also inform the user before beginning sampling, to allow them to inspect the request (human in the loop) and decide whether to approve it. - """ - - model_config = ConfigDict( - extra="ignore", - ) - method: Literal["sampling/createMessage"] - params: CreateMessageRequestParams - - -class CreateMessageRequestParams(WireModel): - """ - Parameters for a `sampling/createMessage` request. - """ - - model_config = ConfigDict( - extra="ignore", - ) - include_context: Annotated[ - Literal["allServers", "none", "thisServer"] | None, - Field(alias="includeContext"), - ] = None - """ - A request to include context from one or more MCP servers (including the caller), to be attached to the prompt. - The client MAY ignore this request. - - Default is `"none"`. The values `"thisServer"` and `"allServers"` are deprecated (SEP-2596): servers SHOULD - omit this field or use `"none"`, and SHOULD only use the deprecated values if the client declares - {@link ClientCapabilities.sampling.context}. - """ - max_tokens: Annotated[int, Field(alias="maxTokens")] - """ - The requested maximum number of tokens to sample (to prevent runaway completions). - - The client MAY choose to sample fewer tokens than the requested maximum. - """ - messages: list[SamplingMessage] - metadata: JSONObject | None = None - """ - Optional metadata to pass through to the LLM provider. The format of this metadata is provider-specific. - """ - model_preferences: Annotated[ModelPreferences | None, Field(alias="modelPreferences")] = None - """ - The server's preferences for which model to select. The client MAY ignore these preferences. - """ - stop_sequences: Annotated[list[str] | None, Field(alias="stopSequences")] = None - system_prompt: Annotated[str | None, Field(alias="systemPrompt")] = None - """ - An optional system prompt the server wants to use for sampling. The client MAY modify or omit this prompt. - """ - temperature: float | None = None - tool_choice: Annotated[ToolChoice | None, Field(alias="toolChoice")] = None - """ - Controls how the model uses tools. - The client MUST return an error if this field is provided but {@link ClientCapabilities.sampling.tools} is not declared. - Default is `{ mode: "auto" }`. - """ - tools: list[Tool] | None = None - """ - Tools that the model may use during generation. - The client MUST return an error if this field is provided but {@link ClientCapabilities.sampling.tools} is not declared. - """ - - -class DiscoverRequest(WireModel): - """ - A request from the client asking the server to advertise its supported - protocol versions, capabilities, and other metadata. Servers **MUST** - implement `server/discover`. Clients **MAY** call it but are not required - to — version negotiation can also happen inline via per-request `_meta`. - """ - - model_config = ConfigDict( - extra="ignore", - ) - id: RequestId - jsonrpc: Literal["2.0"] - method: Literal["server/discover"] - params: RequestParams - - -class DiscoverResult(WireModel): - """ - The result returned by the server for a {@link DiscoverRequestserver/discover} request. - """ - - model_config = ConfigDict( - extra="ignore", - ) - meta: Annotated[ResultMetaObject | None, Field(alias="_meta")] = None - cache_scope: Annotated[Literal["private", "public"], Field(alias="cacheScope")] - """ - Indicates the intended scope of the cached response, analogous to HTTP - `Cache-Control: public` vs `Cache-Control: private`. - - - `"public"`: The response does not contain user-specific data. Any - client or intermediary (e.g., shared gateway, caching proxy) MAY cache - the response and serve it across authorization contexts. - - `"private"`: The response MAY be cached and reused only within the - same authorization context. Caches MUST NOT be shared across - authorization contexts (e.g., a different access token requires a - different cache). - """ - capabilities: ServerCapabilities - """ - The capabilities of the server. - """ - instructions: str | None = None - """ - Natural-language guidance describing the server and its features. - - This can be used by clients to improve an LLM's understanding of - available tools (e.g., by including it in a system prompt). It should - focus on information that helps the model use the server effectively - and should not duplicate information already in tool descriptions. - """ - result_type: Annotated[str, Field(alias="resultType")] - """ - Indicates the type of the result, which allows the client to determine - how to parse the result object. - - Servers implementing this protocol version MUST include this field. - For backward compatibility, when a client receives a result from a - server implementing an earlier protocol version (which does not include - `resultType`), the client MUST treat the absent field as `"complete"`. - """ - supported_versions: Annotated[list[str], Field(alias="supportedVersions")] - """ - MCP Protocol Versions this server supports. The client should choose a - version from this list for use in subsequent requests. - """ - ttl_ms: Annotated[int, Field(alias="ttlMs", ge=0)] - """ - A hint from the server indicating how long (in milliseconds) the - client MAY cache this response before re-fetching. Semantics are - analogous to HTTP Cache-Control max-age. - - - If 0, The response SHOULD be considered immediately stale, - The client MAY re-fetch every time the result is needed. - - If positive, the client SHOULD consider the result fresh for this many - milliseconds after receiving the response. - """ - - -class DiscoverResultResponse(WireModel): - """ - A successful response from the server for a {@link DiscoverRequestserver/discover} request. - """ - - model_config = ConfigDict( - extra="ignore", - ) - id: RequestId - jsonrpc: Literal["2.0"] - result: DiscoverResult - - -class GetPromptRequest(WireModel): - """ - Used by the client to get a prompt provided by the server. - """ - - model_config = ConfigDict( - extra="ignore", - ) - id: RequestId - jsonrpc: Literal["2.0"] - method: Literal["prompts/get"] - params: GetPromptRequestParams - - -class GetPromptRequestParams(WireModel): - """ - Parameters for a `prompts/get` request. - """ - - model_config = ConfigDict( - extra="ignore", - ) - meta: Annotated[RequestMetaObject, Field(alias="_meta")] - arguments: dict[str, str] | None = None - """ - Arguments to use for templating the prompt. - """ - input_responses: Annotated[InputResponses | None, Field(alias="inputResponses")] = None - name: str - """ - The name of the prompt or prompt template. - """ - request_state: Annotated[str | None, Field(alias="requestState")] = None - - -class GetPromptResultResponse(WireModel): - """ - A successful response from the server for a {@link GetPromptRequestprompts/get} request. - """ - - model_config = ConfigDict( - extra="ignore", - ) - id: RequestId - jsonrpc: Literal["2.0"] - result: InputRequiredResult | GetPromptResult - - -class InputRequiredResult(WireModel): - """ - An InputRequiredResult sent by the server to indicate that additional input is needed - before the request can be completed. - - At least one of `inputRequests` or `requestState` MUST be present. - """ - - model_config = ConfigDict( - extra="ignore", - ) - meta: Annotated[ResultMetaObject | None, Field(alias="_meta")] = None - input_requests: Annotated[InputRequests | None, Field(alias="inputRequests")] = None - request_state: Annotated[str | None, Field(alias="requestState")] = None - result_type: Annotated[str, Field(alias="resultType")] - """ - Indicates the type of the result, which allows the client to determine - how to parse the result object. - - Servers implementing this protocol version MUST include this field. - For backward compatibility, when a client receives a result from a - server implementing an earlier protocol version (which does not include - `resultType`), the client MUST treat the absent field as `"complete"`. - """ - - -class InputResponseRequestParams(WireModel): - model_config = ConfigDict( - extra="ignore", - ) - meta: Annotated[RequestMetaObject, Field(alias="_meta")] - input_responses: Annotated[InputResponses | None, Field(alias="inputResponses")] = None - request_state: Annotated[str | None, Field(alias="requestState")] = None +InputResponses = dict[str, InputResponse] +""" +A map of client responses to server-initiated requests. +Keys correspond to the keys in the {@link InputRequests} map; +values are the client's result for each request. +""" class ListPromptsRequest(WireModel): @@ -3351,49 +3301,6 @@ class ListToolsRequest(WireModel): params: PaginatedRequestParams -class Data(WireModel): - """ - Additional information about the error. The value of this member is defined by the sender (e.g. detailed error information, nested errors etc.). - """ - - model_config = ConfigDict( - extra="ignore", - ) - required_capabilities: Annotated[ClientCapabilities, Field(alias="requiredCapabilities")] - """ - The capabilities the server requires from the client to process this request. - """ - - -class Error2(Error): - model_config = ConfigDict( - extra="ignore", - ) - code: Literal[-32021] - """ - The error type that occurred. - """ - data: Data - """ - Additional information about the error. The value of this member is defined by the sender (e.g. detailed error information, nested errors etc.). - """ - - -class MissingRequiredClientCapabilityError(WireModel): - """ - Returned when processing a request requires a capability the client did not - declare in `clientCapabilities`. For HTTP, the response status code MUST be - `400 Bad Request`. - """ - - model_config = ConfigDict( - extra="ignore", - ) - error: Error2 - id: RequestId | None = None - jsonrpc: Literal["2.0"] - - class PaginatedRequest(WireModel): model_config = ConfigDict( extra="ignore", @@ -3404,36 +3311,6 @@ class PaginatedRequest(WireModel): params: PaginatedRequestParams -class PaginatedRequestParams(WireModel): - """ - Common params for paginated requests. - """ - - model_config = ConfigDict( - extra="ignore", - ) - meta: Annotated[RequestMetaObject, Field(alias="_meta")] - cursor: str | None = None - """ - An opaque token representing the current pagination position. - If provided, the server should return results starting after this cursor. - """ - - -class ReadResourceRequest(WireModel): - """ - Sent from the client to the server, to read a specific resource URI. - """ - - model_config = ConfigDict( - extra="ignore", - ) - id: RequestId - jsonrpc: Literal["2.0"] - method: Literal["resources/read"] - params: ReadResourceRequestParams - - class ReadResourceRequestParams(WireModel): """ Parameters for a `resources/read` request. @@ -3451,6 +3328,221 @@ class ReadResourceRequestParams(WireModel): """ +class SamplingMessage(WireModel): + """ + Describes a message issued to or received from an LLM API. + """ + + model_config = ConfigDict( + extra="ignore", + ) + meta: Annotated[MetaObject | None, Field(alias="_meta")] = None + content: ( + TextContent + | ImageContent + | AudioContent + | ToolUseContent + | ToolResultContent + | list[SamplingMessageContentBlock] + ) + role: Role + + +class CallToolRequestParams(WireModel): + """ + Parameters for a `tools/call` request. + """ + + model_config = ConfigDict( + extra="ignore", + ) + meta: Annotated[RequestMetaObject, Field(alias="_meta")] + arguments: dict[str, Any] | None = None + """ + Arguments to use for the tool call. + """ + input_responses: Annotated[InputResponses | None, Field(alias="inputResponses")] = None + name: str + """ + The name of the tool. + """ + request_state: Annotated[str | None, Field(alias="requestState")] = None + + +class CreateMessageRequestParams(WireModel): + """ + Parameters for a `sampling/createMessage` request. + """ + + model_config = ConfigDict( + extra="ignore", + ) + include_context: Annotated[ + Literal["allServers", "none", "thisServer"] | None, + Field(alias="includeContext"), + ] = None + """ + A request to include context from one or more MCP servers (including the caller), to be attached to the prompt. + The client MAY ignore this request. + + Default is `"none"`. The values `"thisServer"` and `"allServers"` are deprecated (SEP-2596): servers SHOULD + omit this field or use `"none"`, and SHOULD only use the deprecated values if the client declares + {@link ClientCapabilities.sampling.context}. + """ + max_tokens: Annotated[int, Field(alias="maxTokens")] + """ + The requested maximum number of tokens to sample (to prevent runaway completions). + + The client MAY choose to sample fewer tokens than the requested maximum. + """ + messages: list[SamplingMessage] + metadata: JSONObject | None = None + """ + Optional metadata to pass through to the LLM provider. The format of this metadata is provider-specific. + """ + model_preferences: Annotated[ModelPreferences | None, Field(alias="modelPreferences")] = None + """ + The server's preferences for which model to select. The client MAY ignore these preferences. + """ + stop_sequences: Annotated[list[str] | None, Field(alias="stopSequences")] = None + system_prompt: Annotated[str | None, Field(alias="systemPrompt")] = None + """ + An optional system prompt the server wants to use for sampling. The client MAY modify or omit this prompt. + """ + temperature: float | None = None + tool_choice: Annotated[ToolChoice | None, Field(alias="toolChoice")] = None + """ + Controls how the model uses tools. + The client MUST return an error if this field is provided but {@link ClientCapabilities.sampling.tools} is not declared. + Default is `{ mode: "auto" }`. + """ + tools: list[Tool] | None = None + """ + Tools that the model may use during generation. + The client MUST return an error if this field is provided but {@link ClientCapabilities.sampling.tools} is not declared. + """ + + +class GetPromptRequestParams(WireModel): + """ + Parameters for a `prompts/get` request. + """ + + model_config = ConfigDict( + extra="ignore", + ) + meta: Annotated[RequestMetaObject, Field(alias="_meta")] + arguments: dict[str, str] | None = None + """ + Arguments to use for templating the prompt. + """ + input_responses: Annotated[InputResponses | None, Field(alias="inputResponses")] = None + name: str + """ + The name of the prompt or prompt template. + """ + request_state: Annotated[str | None, Field(alias="requestState")] = None + + +class InputResponseRequestParams(WireModel): + model_config = ConfigDict( + extra="ignore", + ) + meta: Annotated[RequestMetaObject, Field(alias="_meta")] + input_responses: Annotated[InputResponses | None, Field(alias="inputResponses")] = None + request_state: Annotated[str | None, Field(alias="requestState")] = None + + +class ReadResourceRequest(WireModel): + """ + Sent from the client to the server, to read a specific resource URI. + """ + + model_config = ConfigDict( + extra="ignore", + ) + id: RequestId + jsonrpc: Literal["2.0"] + method: Literal["resources/read"] + params: ReadResourceRequestParams + + +class CallToolRequest(WireModel): + """ + Used by the client to invoke a tool provided by the server. + """ + + model_config = ConfigDict( + extra="ignore", + ) + id: RequestId + jsonrpc: Literal["2.0"] + method: Literal["tools/call"] + params: CallToolRequestParams + + +class CreateMessageRequest(WireModel): + """ + A request from the server to sample an LLM via the client. The client has full discretion over which model to select. The client should also inform the user before beginning sampling, to allow them to inspect the request (human in the loop) and decide whether to approve it. + """ + + model_config = ConfigDict( + extra="ignore", + ) + method: Literal["sampling/createMessage"] + params: CreateMessageRequestParams + + +class GetPromptRequest(WireModel): + """ + Used by the client to get a prompt provided by the server. + """ + + model_config = ConfigDict( + extra="ignore", + ) + id: RequestId + jsonrpc: Literal["2.0"] + method: Literal["prompts/get"] + params: GetPromptRequestParams + + +InputRequest = CreateMessageRequest | ListRootsRequest | ElicitRequest + + +InputRequests = dict[str, InputRequest] +""" +A map of server-initiated requests that the client must fulfill. +Keys are server-assigned identifiers; values are the request objects. +""" + + +class InputRequiredResult(WireModel): + """ + An InputRequiredResult sent by the server to indicate that additional input is needed + before the request can be completed. + + At least one of `inputRequests` or `requestState` MUST be present. + """ + + model_config = ConfigDict( + extra="ignore", + ) + meta: Annotated[ResultMetaObject | None, Field(alias="_meta")] = None + input_requests: Annotated[InputRequests | None, Field(alias="inputRequests")] = None + request_state: Annotated[str | None, Field(alias="requestState")] = None + result_type: Annotated[str, Field(alias="resultType")] + """ + Indicates the type of the result, which allows the client to determine + how to parse the result object. + + Servers implementing this protocol version MUST include this field. + For backward compatibility, when a client receives a result from a + server implementing an earlier protocol version (which does not include + `resultType`), the client MUST treat the absent field as `"complete"`. + """ + + class ReadResourceResultResponse(WireModel): """ A successful response from the server for a {@link ReadResourceRequestresources/read} request. @@ -3464,139 +3556,25 @@ class ReadResourceResultResponse(WireModel): result: InputRequiredResult | ReadResourceResult -class RequestMetaObject(WireModel): - """ - Extends {@link MetaObject} with additional request-specific fields. All key naming rules from `MetaObject` apply. - """ - - model_config = ConfigDict( - extra="allow", - ) - io_modelcontextprotocol_client_capabilities: Annotated[ - ClientCapabilities, Field(alias="io.modelcontextprotocol/clientCapabilities") - ] - """ - The client's capabilities for this specific request. Required. - - Capabilities are declared per-request rather than once at initialization; - an empty object means the client supports no optional capabilities. - Servers MUST NOT infer capabilities from prior requests. - """ - io_modelcontextprotocol_client_info: Annotated[ - Implementation | None, Field(alias="io.modelcontextprotocol/clientInfo") - ] = None - """ - Identifies the client software making the request. Clients SHOULD - include this field on every request unless specifically configured not - to do so. - - The {@link Implementation} schema requires `name` and `version`; other - fields are optional. - - The value is self-reported by the client and is not verified by the - protocol. It is intended for display, logging, and debugging. Servers - SHOULD NOT use it to change their behavior, and SHOULD NOT rely on it for - security decisions. - """ - io_modelcontextprotocol_log_level: Annotated[ - LoggingLevel | None, Field(alias="io.modelcontextprotocol/logLevel") - ] = None - """ - The desired log level for this request. Optional. - - If absent, the server MUST NOT send any {@link LoggingMessageNotificationnotifications/message} - notifications for this request. The client opts in to log messages by - explicitly setting a level. Replaces the former `logging/setLevel` RPC. - """ - io_modelcontextprotocol_protocol_version: Annotated[str, Field(alias="io.modelcontextprotocol/protocolVersion")] - """ - The MCP Protocol Version being used for this request. Required. - - For the HTTP transport, this value MUST match the `MCP-Protocol-Version` - header; otherwise the server MUST return a `400 Bad Request`. If the - server does not support the requested version, it MUST return an - {@link UnsupportedProtocolVersionError}. - """ - progress_token: Annotated[ProgressToken | None, Field(alias="progressToken")] = None - """ - If specified, the caller is requesting out-of-band progress notifications for this request (as represented by {@link ProgressNotificationnotifications/progress}). The value of this parameter is an opaque token that will be attached to any subsequent notifications. The receiver is not obligated to provide these notifications. - """ +ServerResult = ( + Result + | InputRequiredResult + | DiscoverResult + | ListResourcesResult + | ListResourceTemplatesResult + | ReadResourceResult + | SubscriptionsListenResult + | ListPromptsResult + | GetPromptResult + | ListToolsResult + | CallToolResult + | CompleteResult +) -class RequestParams(WireModel): +class CallToolResultResponse(WireModel): """ - Common params for any request. - """ - - model_config = ConfigDict( - extra="ignore", - ) - meta: Annotated[RequestMetaObject, Field(alias="_meta")] - - -class ResourceRequestParams(WireModel): - """ - Common params for resource-related requests. - """ - - model_config = ConfigDict( - extra="ignore", - ) - meta: Annotated[RequestMetaObject, Field(alias="_meta")] - uri: str - """ - The URI of the resource. The URI can use any protocol; it is up to the server how to interpret it. - """ - - -class ServerCapabilities(WireModel): - """ - Capabilities that a server may support. Known capabilities are defined here, in this schema, but this is not a closed set: any server can define its own, additional capabilities. - """ - - model_config = ConfigDict( - extra="ignore", - ) - completions: JSONObject | None = None - """ - Present if the server supports argument autocompletion suggestions. - """ - experimental: dict[str, JSONObject] | None = None - """ - Experimental, non-standard capabilities that the server supports. - """ - extensions: dict[str, JSONObject] | None = None - """ - Optional MCP extensions that the server supports. Keys are extension identifiers - (e.g., "io.modelcontextprotocol/tasks"), and values are per-extension settings - objects. An empty object indicates support with no settings. - - Keys MUST follow the {@link MetaObject`_meta` key naming rules}, with a - mandatory prefix. - """ - logging: JSONObject | None = None - """ - Present if the server supports sending log messages to the client. - """ - prompts: Prompts | None = None - """ - Present if the server offers any prompt templates. - """ - resources: Resources | None = None - """ - Present if the server offers any resources to read. - """ - tools: Tools | None = None - """ - Present if the server offers any tools to call. - """ - - -class SubscriptionsListenRequest(WireModel): - """ - Sent from the client to open a long-lived channel for receiving notifications - outside the context of a specific request. Replaces the previous HTTP GET - endpoint and ensures consistent behavior between HTTP and STDIO. + A successful response from the server for a {@link CallToolRequesttools/call} request. """ model_config = ConfigDict( @@ -3604,143 +3582,36 @@ class SubscriptionsListenRequest(WireModel): ) id: RequestId jsonrpc: Literal["2.0"] - method: Literal["subscriptions/listen"] - params: SubscriptionsListenRequestParams + result: InputRequiredResult | CallToolResult -class SubscriptionsListenRequestParams(WireModel): +ClientRequest = ( + DiscoverRequest + | ListResourcesRequest + | ListResourceTemplatesRequest + | ReadResourceRequest + | SubscriptionsListenRequest + | ListPromptsRequest + | GetPromptRequest + | ListToolsRequest + | CallToolRequest + | CompleteRequest +) + + +class GetPromptResultResponse(WireModel): """ - Parameters for a {@link SubscriptionsListenRequestsubscriptions/listen} request. + A successful response from the server for a {@link GetPromptRequestprompts/get} request. """ model_config = ConfigDict( extra="ignore", ) - meta: Annotated[RequestMetaObject, Field(alias="_meta")] - notifications: SubscriptionFilter - """ - The notifications the client opts in to on this stream. The server - **MUST NOT** send notification types the client has not explicitly - requested. - """ - - -class InputRequest(RootModel[CreateMessageRequest | ListRootsRequest | ElicitRequest]): - root: CreateMessageRequest | ListRootsRequest | ElicitRequest - - -class ServerResult( - RootModel[ - Result - | InputRequiredResult - | DiscoverResult - | ListResourcesResult - | ListResourceTemplatesResult - | ReadResourceResult - | SubscriptionsListenResult - | ListPromptsResult - | GetPromptResult - | ListToolsResult - | CallToolResult - | CompleteResult - ] -): - root: ( - Result - | InputRequiredResult - | DiscoverResult - | ListResourcesResult - | ListResourceTemplatesResult - | ReadResourceResult - | SubscriptionsListenResult - | ListPromptsResult - | GetPromptResult - | ListToolsResult - | CallToolResult - | CompleteResult - ) - - -class ClientRequest( - RootModel[ - DiscoverRequest - | ListResourcesRequest - | ListResourceTemplatesRequest - | ReadResourceRequest - | SubscriptionsListenRequest - | ListPromptsRequest - | GetPromptRequest - | ListToolsRequest - | CallToolRequest - | CompleteRequest - ] -): - root: ( - DiscoverRequest - | ListResourcesRequest - | ListResourceTemplatesRequest - | ReadResourceRequest - | SubscriptionsListenRequest - | ListPromptsRequest - | GetPromptRequest - | ListToolsRequest - | CallToolRequest - | CompleteRequest - ) - - -class InputRequests(RootModel[dict[str, InputRequest]]): - """ - A map of server-initiated requests that the client must fulfill. - Keys are server-assigned identifiers; values are the request objects. - """ - - root: dict[str, InputRequest] - - -class JSONArray(RootModel[list["JSONValue"]]): - root: list["JSONValue"] - - -class JSONObject(RootModel[dict[str, "JSONValue"]]): - root: dict[str, "JSONValue"] - - -class JSONValue(RootModel[Union[JSONObject, list["JSONValue"], str | int | float | bool | None]]): - root: Union[JSONObject, list["JSONValue"], str | int | float | bool | None] + id: RequestId + jsonrpc: Literal["2.0"] + result: InputRequiredResult | GetPromptResult AnyCallToolResult = CallToolResult | InputRequiredResult AnyGetPromptResult = GetPromptResult | InputRequiredResult AnyReadResourceResult = ReadResourceResult | InputRequiredResult - - -CallToolRequest.model_rebuild() -CallToolRequestParams.model_rebuild() -CallToolResultResponse.model_rebuild() -Elicitation.model_rebuild() -Sampling.model_rebuild() -ClientCapabilities.model_rebuild() -CompleteRequest.model_rebuild() -CompleteRequestParams.model_rebuild() -CreateMessageRequest.model_rebuild() -CreateMessageRequestParams.model_rebuild() -DiscoverRequest.model_rebuild() -DiscoverResult.model_rebuild() -GetPromptRequest.model_rebuild() -GetPromptRequestParams.model_rebuild() -GetPromptResultResponse.model_rebuild() -InputRequiredResult.model_rebuild() -InputResponseRequestParams.model_rebuild() -ListPromptsRequest.model_rebuild() -ListResourceTemplatesRequest.model_rebuild() -ListResourcesRequest.model_rebuild() -ListToolsRequest.model_rebuild() -PaginatedRequest.model_rebuild() -PaginatedRequestParams.model_rebuild() -ReadResourceRequest.model_rebuild() -ReadResourceRequestParams.model_rebuild() -ServerCapabilities.model_rebuild() -SubscriptionsListenRequest.model_rebuild() -JSONArray.model_rebuild() -JSONObject.model_rebuild() diff --git a/src/mcp/client/session.py b/src/mcp/client/session.py index aa2406dc..a6181121 100644 --- a/src/mcp/client/session.py +++ b/src/mcp/client/session.py @@ -80,19 +80,12 @@ def _clamp_inbound_ttl(raw: dict[str, Any]) -> None: @cache def _wire_fields(target: type[BaseModel] | UnionType) -> frozenset[str]: - """Top-level wire keys `target` declares (its members', for a union). - - A `RootModel` row (e.g. an empty result carried as `RootModel[Result]`) - reports its wrapped type's keys, not the pydantic-internal `root`. - """ + """Top-level wire keys `target` declares (its members', for a union).""" members: tuple[Any, ...] = get_args(target) if isinstance(target, UnionType) else (target,) models = [m for m in members if isinstance(m, type) and issubclass(m, BaseModel)] fields: set[str] = set() for model in models: - if getattr(model, "__pydantic_root_model__", False): # a RootModel wrapper row - fields |= _wire_fields(model.model_fields["root"].annotation) - else: - fields.update(field.alias or name for name, field in model.model_fields.items()) + fields.update(field.alias or name for name, field in model.model_fields.items()) return frozenset(fields) diff --git a/src/mcp/server/auth/handlers/authorize.py b/src/mcp/server/auth/handlers/authorize.py index 5cf93cf8..91955eb9 100644 --- a/src/mcp/server/auth/handlers/authorize.py +++ b/src/mcp/server/auth/handlers/authorize.py @@ -2,8 +2,7 @@ import logging from dataclasses import dataclass from typing import Any, Literal -# TODO(Marcelo): We should drop the `RootModel`. -from pydantic import AnyUrl, BaseModel, Field, RootModel, ValidationError # noqa: TID251 +from pydantic import AnyUrl, BaseModel, Field, TypeAdapter, ValidationError from starlette.datastructures import FormData, QueryParams from starlette.requests import Request from starlette.responses import RedirectResponse, Response @@ -20,6 +19,7 @@ from mcp.server.auth.provider import ( from mcp.shared.auth import InvalidRedirectUriError, InvalidScopeError logger = logging.getLogger(__name__) +_ANY_URL_ADAPTER = TypeAdapter(AnyUrl) class AuthorizationRequest(BaseModel): @@ -59,10 +59,6 @@ def best_effort_extract_string(key: str, params: None | FormData | QueryParams) return None -class AnyUrlModel(RootModel[AnyUrl]): - root: AnyUrl - - @dataclass class AuthorizationHandler: provider: OAuthAuthorizationServerProvider[Any, Any, Any] @@ -107,9 +103,9 @@ class AuthorizationHandler: if params is not None and "redirect_uri" not in params: raw_redirect_uri = None else: - raw_redirect_uri = AnyUrlModel.model_validate( + raw_redirect_uri = _ANY_URL_ADAPTER.validate_python( best_effort_extract_string("redirect_uri", params) - ).root + ) redirect_uri = client.validate_redirect_uri(raw_redirect_uri) except (ValidationError, InvalidRedirectUriError): # if the redirect URI is invalid, ignore it & just return the diff --git a/src/mcp/server/elicitation.py b/src/mcp/server/elicitation.py index 26425c13..65037888 100644 --- a/src/mcp/server/elicitation.py +++ b/src/mcp/server/elicitation.py @@ -8,7 +8,7 @@ from mcp_types import RequestId # Internal surface package; imported as the gate's source of truth for spec-valid property schemas. from mcp_types._v2025_11_25 import PrimitiveSchemaDefinition -from pydantic import BaseModel, ValidationError +from pydantic import BaseModel, TypeAdapter, ValidationError from pydantic.json_schema import GenerateJsonSchema, JsonSchemaValue from pydantic_core import core_schema from typing_extensions import TypeAliasType @@ -16,6 +16,7 @@ from typing_extensions import TypeAliasType from mcp.server.session import ServerSession ElicitSchemaModelT = TypeVar("ElicitSchemaModelT", bound=BaseModel) +_PRIMITIVE_SCHEMA_ADAPTER = TypeAdapter[PrimitiveSchemaDefinition](PrimitiveSchemaDefinition) class AcceptedElicitation(BaseModel, Generic[ElicitSchemaModelT]): @@ -79,7 +80,7 @@ def _validate_rendered_properties(json_schema: dict[str, Any]) -> None: """ for field_name, prop in json_schema.get("properties", {}).items(): try: - PrimitiveSchemaDefinition.model_validate(prop) + _PRIMITIVE_SCHEMA_ADAPTER.validate_python(prop) except ValidationError: raise TypeError( f"Elicitation schema field {field_name!r} rendered as {prop!r}, " diff --git a/src/mcp/server/mcpserver/utilities/func_metadata.py b/src/mcp/server/mcpserver/utilities/func_metadata.py index cc324335..0ffac07c 100644 --- a/src/mcp/server/mcpserver/utilities/func_metadata.py +++ b/src/mcp/server/mcpserver/utilities/func_metadata.py @@ -114,15 +114,15 @@ class ArgModelBase(BaseModel): class FuncMetadata(BaseModel): """A tool function's argument model plus, for structured output, the published `output_schema` and the - `output_model` results are validated against. Constructing one with an `output_model` and no schema derives - the schema (and raises if pydantic can't); the fields are read live, so clearing or reassigning them later + `output_model` type annotation results are validated against. Constructing one with an `output_model` and no + schema derives the schema (and raises if pydantic can't); the fields are read live, so reassigning them later takes effect on the next call.""" arg_model: Annotated[type[ArgModelBase], WithJsonSchema(None)] output_schema: dict[str, Any] | None = None - output_model: Annotated[type[Any], WithJsonSchema(None)] | None = None + output_model: Annotated[Any, WithJsonSchema(None)] = None wrap_output: bool = False - _adapter: tuple[type[Any], TypeAdapter[Any]] | None = PrivateAttr(default=None) + _adapter: tuple[Any, TypeAdapter[Any]] | None = PrivateAttr(default=None) def model_post_init(self, context: Any, /) -> None: if self.output_model is not None and self.output_schema is None: @@ -130,7 +130,7 @@ class FuncMetadata(BaseModel): schema = self._output_adapter(self.output_model).json_schema(schema_generator=StrictJsonSchema) self.output_schema = _inline_root_ref(schema) - def _output_adapter(self, output_model: type[Any]) -> TypeAdapter[Any]: + def _output_adapter(self, output_model: Any) -> TypeAdapter[Any]: """The validator/serializer for `output_model`, built once and rebuilt only if the field is reassigned.""" if self._adapter is None or self._adapter[0] is not output_model: self._adapter = (output_model, TypeAdapter(_pydantic_readable_typeddict(output_model))) @@ -477,7 +477,7 @@ def func_metadata( return FuncMetadata(arg_model=arguments_model) -def _create_output_model(original_annotation: Any, type_expr: Any, func_name: str) -> tuple[type[Any] | None, bool]: +def _create_output_model(original_annotation: Any, type_expr: Any, func_name: str) -> tuple[Any, bool]: """Pick the type structured output is validated against for the given return annotation. Args: @@ -491,7 +491,7 @@ def _create_output_model(original_annotation: Any, type_expr: Any, func_name: st Model is None if the type cannot carry structured output. wrap_output is True if the result needs to be wrapped in {"result": ...} """ - model: type[Any] | None = None + model: Any = None wrap_output = False # First handle special case: None @@ -503,13 +503,12 @@ def _create_output_model(original_annotation: Any, type_expr: Any, func_name: st elif isinstance(type_expr, GenericAlias): origin = get_origin(type_expr) - # Special case: dict with string keys can use RootModel if origin is dict: args = get_args(type_expr) if len(args) == 2 and args[0] is str: # TODO: should we use the original annotation? We are losing any potential `Annotated` # metadata for Pydantic here: - model = _create_dict_model(func_name, type_expr) + model = Annotated[type_expr, Field(title=f"{func_name}DictOutput")] else: # dict with non-str keys needs wrapping model = _create_wrapped_model(func_name, original_annotation) @@ -622,21 +621,6 @@ def _create_wrapped_model(func_name: str, annotation: Any) -> type[BaseModel]: return create_model(model_name, result=annotation) -def _create_dict_model(func_name: str, dict_annotation: Any) -> type[BaseModel]: - """Create a RootModel for dict[str, T] types.""" - # TODO(Marcelo): We should not rely on RootModel for this. - from pydantic import RootModel # noqa: TID251 - - class DictModel(RootModel[dict_annotation]): - pass - - # Give it a meaningful name - DictModel.__name__ = f"{func_name}DictOutput" - DictModel.__qualname__ = f"{func_name}DictOutput" - - return DictModel - - def _convert_to_content(result: Any) -> list[ContentBlock]: """Convert a result to a sequence of content objects. diff --git a/tests/types/test_methods.py b/tests/types/test_methods.py index bd0d1f9b..d38dfa09 100644 --- a/tests/types/test_methods.py +++ b/tests/types/test_methods.py @@ -454,11 +454,20 @@ def test_elicit_result_surface_accepts_null_content_values_at_every_version_that surface.model_validate({"action": "accept", "content": {"name": "x", "age": None}}) -def test_server_capabilities_extensions_with_null_json_value_round_trips_at_2026(): - """Spec `JSONValue` includes `null`; the ts->json render dropped it from the vendored schema.""" - raw: dict[str, Any] = {"extensions": {"x": {"k": None}}} - parsed = v2026.ServerCapabilities.model_validate(raw) - assert parsed.model_dump(mode="json")["extensions"] == {"x": {"k": None}} +def test_discovery_capabilities_preserve_nested_json_values() -> None: + """Spec `JSONValue` permits nested containers and every JSON scalar, including numbers and null.""" + extensions = {"x": {"nested": [None, True, 1, 1.5, "text", {"child": [False]}]}} + raw = { + "supportedVersions": ["2026-07-28"], + "capabilities": {"extensions": extensions}, + "resultType": "complete", + "ttlMs": 0, + "cacheScope": "private", + } + parsed = methods.parse_server_result("server/discover", "2026-07-28", raw) + assert isinstance(parsed, types.DiscoverResult) + assert parsed.capabilities.extensions == extensions + assert methods.serialize_server_result("server/discover", "2026-07-28", raw) == raw def test_elicit_request_surface_accepts_loose_property_schemas(): diff --git a/tests/types/test_parity.py b/tests/types/test_parity.py index 35319921..fe8ac758 100644 --- a/tests/types/test_parity.py +++ b/tests/types/test_parity.py @@ -153,8 +153,6 @@ def _surface_classes(module: ModuleType) -> list[tuple[str, type[BaseModel]]]: continue if obj.__module__ != module.__name__ or obj.__name__ != name: continue # re-export or alias to another model - if getattr(obj, "__pydantic_root_model__", False): - continue # RootModel alias wrapper; the field-subset property does not apply out.append((f"{tail}.{name}", obj)) return out From 6affe5c0d3588fd1705713b3703dc68015cfe3eb Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Wed, 16 Sep 2026 12:12:27 +0200 Subject: [PATCH 20/20] Detect event-loop blocking in tests (#3510) --- pyproject.toml | 1 + src/mcp/client/stdio.py | 9 ++++---- tests/client/test_stdio.py | 47 ++++++++++++++++++++++++++++++++++++++ tests/conftest.py | 26 +++++++++++++++++++++ uv.lock | 20 ++++++++++++++++ 5 files changed, 99 insertions(+), 4 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 536ef527..b2f26da5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -77,6 +77,7 @@ dev = [ "strict-no-cover", "logfire>=3.0.0", "opentelemetry-sdk>=1.39.1", + "blockbuster>=1.5.27", ] docs = [ # Zensical is the Material team's successor to MkDocs; it natively diff --git a/src/mcp/client/stdio.py b/src/mcp/client/stdio.py index 3e03eef9..6a3ad121 100644 --- a/src/mcp/client/stdio.py +++ b/src/mcp/client/stdio.py @@ -18,6 +18,7 @@ from typing import Literal, TextIO import anyio import anyio.lowlevel +import anyio.to_thread import mcp_types as types from anyio.abc import AsyncResource, Process from anyio.streams.text import TextReceiveStream @@ -120,7 +121,7 @@ async def stdio_client( OSError: If the server process cannot be spawned. ValueError: If the spawn parameters are invalid (embedded NUL bytes). """ - command = _get_executable_command(server.command) + command = await _get_executable_command(server.command) process = await _create_platform_compatible_process( command=command, @@ -317,10 +318,10 @@ def _close_subprocess_transport(process: ServerProcess) -> None: close() -def _get_executable_command(command: str) -> str: +async def _get_executable_command(command: str) -> str: """Normalizes the command for the current platform.""" - if sys.platform == "win32": # pragma: no cover - return get_windows_executable_command(command) + if sys.platform == "win32": + return await anyio.to_thread.run_sync(get_windows_executable_command, command, abandon_on_cancel=True) else: # pragma: lax no cover return command diff --git a/tests/client/test_stdio.py b/tests/client/test_stdio.py index 91f829ff..0101e45a 100644 --- a/tests/client/test_stdio.py +++ b/tests/client/test_stdio.py @@ -14,14 +14,18 @@ import math import os import signal import sys +import threading from collections.abc import Callable from contextlib import AsyncExitStack, suppress from pathlib import Path +from types import SimpleNamespace from typing import TextIO, cast import anyio import anyio.abc +import anyio.from_thread import anyio.lowlevel +import anyio.to_thread import pytest import trio import trio.testing @@ -199,6 +203,9 @@ def install_fake_process( """ terminated: list[FakeProcess] = [] + async def fake_get_executable_command(command: str) -> str: + return command + async def fake_spawn( command: str, args: list[str], @@ -212,6 +219,7 @@ def install_fake_process( terminated.append(proc) proc.exit(-15) + monkeypatch.setattr(stdio, "_get_executable_command", fake_get_executable_command) monkeypatch.setattr(stdio, "_create_platform_compatible_process", fake_spawn) monkeypatch.setattr(stdio, "_terminate_process_tree", fake_terminate_tree) if grace_period is not None: @@ -568,6 +576,45 @@ async def test_a_command_that_cannot_be_execed_raises_enoent() -> None: assert exc_info.value.errno == errno.ENOENT +@pytest.mark.anyio +async def test_cancellation_during_windows_command_resolution_returns_before_resolution_finishes( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Cancelling `stdio_client` does not wait for blocked Windows command resolution.""" + resolution_started = anyio.Event() + resolution_release = threading.Event() + resolution_finished = threading.Event() + + def blocking_resolver(command: str) -> str: + anyio.from_thread.run_sync(resolution_started.set) + resolution_release.wait() + resolution_finished.set() + return command + + monkeypatch.setattr(stdio, "sys", SimpleNamespace(platform="win32")) + monkeypatch.setattr(stdio, "get_windows_executable_command", blocking_resolver) + + cancel_scope = anyio.CancelScope() + client_stopped = anyio.Event() + + async def run_client() -> None: + with cancel_scope: + async with AsyncExitStack() as stack: + await stack.enter_async_context(stdio_client(FAKE_PARAMS)) + client_stopped.set() + + with anyio.fail_after(5): + async with anyio.create_task_group() as tg: + tg.start_soon(run_client) + await resolution_started.wait() + cancel_scope.cancel() + try: + await client_stopped.wait() + finally: + resolution_release.set() + await anyio.to_thread.run_sync(resolution_finished.wait) + + @pytest.mark.anyio async def test_cancellation_during_spawn_leaks_no_streams(monkeypatch: pytest.MonkeyPatch) -> None: """Cancellation while the spawn is still in flight must not leak the internal streams. diff --git a/tests/conftest.py b/tests/conftest.py index 9ade27e7..4350b76f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,7 +1,9 @@ import os from collections.abc import AsyncIterator, Iterator +import httpcore2 as _httpcore2 import pytest +from blockbuster import BlockBuster # OpenTelemetry's `set_tracer_provider` is set-once per process, so the suite # uses a single span-capture mechanism: logfire's `capfire` fixture (its @@ -17,12 +19,36 @@ from logfire.testing import CaptureLogfire # noqa: E402 import mcp.shared._otel # noqa: E402 +# Load httpx2's lazy default transport before BlockBuster starts. +del _httpcore2 + @pytest.fixture(scope="session") def anyio_backend() -> str: return "asyncio" +@pytest.fixture(autouse=True) +def blockbuster() -> Iterator[None]: + bb = BlockBuster(["mcp", "mcp_types"]) + # Coverage reads source files while collecting data. + bb.functions["os.stat"].can_block_in("coverage/python.py", "get_python_source") + bb.functions["io.BufferedReader.read"].can_block_in("coverage/python.py", "read_python_source") + # jsonschema discovers its bundled schemas during its first import. + bb.functions["os.listdir"].can_block_in("/jsonschema_specifications/_core.py", "_schemas") + bb.functions["os.scandir"].can_block_in("/jsonschema_specifications/_core.py", "_schemas") + bb.functions["io.TextIOWrapper.read"].can_block_in("/jsonschema_specifications/_core.py", "_schemas") + # These public synchronous conversions read the media file by design. + bb.functions["io.BufferedReader.read"].can_block_in( + "mcp/server/mcpserver/utilities/types.py", ("to_image_content", "to_audio_content") + ) + bb.activate() + try: + yield + finally: + bb.deactivate() + + @pytest.fixture(scope="module", autouse=True) async def _module_runner_lease(anyio_backend: str) -> AsyncIterator[None]: """Share one event loop across each module's tests instead of one per test. diff --git a/uv.lock b/uv.lock index ebc03ffa..40b563e9 100644 --- a/uv.lock +++ b/uv.lock @@ -154,6 +154,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8e/0d/52d98722666d6fc6c3dd4c76df339501d6efd40e0ff95e6186a7b7f0befd/black-26.3.1-py3-none-any.whl", hash = "sha256:2bd5aa94fc267d38bb21a70d7410a89f1a1d318841855f698746f8e7f51acd1b", size = 207542, upload-time = "2026-03-12T03:36:01.668Z" }, ] +[[package]] +name = "blockbuster" +version = "1.5.27" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "forbiddenfruit", marker = "implementation_name == 'cpython'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ff/c3/21678f5b979be2cbf0e68352f7330a84ea4e24674023e4de981c78a218cd/blockbuster-1.5.27.tar.gz", hash = "sha256:b8e9d988b9b91ba468c94530e219f26a00d3ff616b39ebf3da561a2a3eea9dd4", size = 96732, upload-time = "2026-08-17T23:53:13.378Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/c5/092e631bc1fba86f0a822be65c137c90a71b71ba0a0865e7e9a21f6ca05e/blockbuster-1.5.27-py3-none-any.whl", hash = "sha256:f0acf153d22a791bf5f142935332ef8530960ec215541b48a6037e6cea0a8645", size = 13517, upload-time = "2026-08-17T23:53:14.625Z" }, +] + [[package]] name = "certifi" version = "2025.8.3" @@ -577,6 +589,12 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c1/ea/53f2148663b321f21b5a606bd5f191517cf40b7072c0497d3c92c4a13b1e/executing-2.2.1-py2.py3-none-any.whl", hash = "sha256:760643d3452b4d777d295bb167ccc74c64a81df23fb5e08eff250c425a4b2017", size = 28317, upload-time = "2025-09-01T09:48:08.5Z" }, ] +[[package]] +name = "forbiddenfruit" +version = "0.1.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e6/79/d4f20e91327c98096d605646bdc6a5ffedae820f38d378d3515c42ec5e60/forbiddenfruit-0.1.4.tar.gz", hash = "sha256:e3f7e66561a29ae129aac139a85d610dbf3dd896128187ed5454b6421f624253", size = 43756, upload-time = "2021-01-16T21:03:35.401Z" } + [[package]] name = "genson" version = "1.3.0" @@ -1023,6 +1041,7 @@ codegen = [ { name = "datamodel-code-generator" }, ] dev = [ + { name = "blockbuster" }, { name = "coverage", extra = ["toml"] }, { name = "dirty-equals" }, { name = "inline-snapshot" }, @@ -1081,6 +1100,7 @@ provides-extras = ["cli", "rich"] [package.metadata.requires-dev] codegen = [{ name = "datamodel-code-generator", specifier = "==0.57.0" }] dev = [ + { name = "blockbuster", specifier = ">=1.5.27" }, { name = "coverage", extras = ["toml"], specifier = ">=7.10.7,<=7.13" }, { name = "dirty-equals", specifier = ">=0.9.0" }, { name = "inline-snapshot", specifier = ">=0.23.0" },