From 3cff974e16ea32bf97208fae6144283bd5b47cd8 Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Tue, 11 Aug 2026 00:09:20 +0200 Subject: [PATCH 01/13] [Docs] Render guides with Zensical, deploy via GitHub Pages actions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit phpDocumentor's guide renderer copies the markdown through mostly verbatim: relative links between pages keep pointing at `*.md` targets that do not exist in the built site, and nothing validates them, so the published guides are full of dead links. Zensical (the Material for MkDocs team's successor to MkDocs) resolves internal links against the page tree and fails the build on a broken one. `zensical build --strict` already found four dead links on the first run — three repo-relative links escaping docs/ (fixed to point at GitHub) and one wrong in-page anchor. phpDocumentor stays on for the class-level API reference only; `make docs` builds the guides into site/ and mounts the reference at site/api/, so its two header links back to the guides now target the site root. Deployment moves from pushing a gh-pages branch to the official GitHub Pages actions, and from release-only to every push on main, with pull requests building (but not deploying) so a broken link fails review instead of the site. NOTE: this needs the repository's Pages source switched to "GitHub Actions" (Settings -> Pages) once. --- .gitattributes | 2 + .github/workflows/docs.yml | 76 +++++++++++++++---- .github/workflows/pipeline.yaml | 4 +- .gitignore | 4 + .phpdoc/template/base.html.twig | 2 +- .../components/header-title.html.twig | 2 +- Makefile | 27 +++++-- docs/CNAME | 1 + docs/authorization.md | 6 +- docs/extensions.md | 2 +- docs/server-client-communication.md | 2 +- mkdocs.yml | 44 +++++++++++ phpdoc.dist.xml | 17 ++--- requirements-docs.txt | 8 ++ 14 files changed, 161 insertions(+), 36 deletions(-) create mode 100644 docs/CNAME create mode 100644 mkdocs.yml create mode 100644 requirements-docs.txt diff --git a/.gitattributes b/.gitattributes index 27224aea..b7b2a18d 100644 --- a/.gitattributes +++ b/.gitattributes @@ -5,6 +5,8 @@ /tests export-ignore /.php-cs-fixer.dist.php export-ignore /Makefile export-ignore +/mkdocs.yml export-ignore +/requirements-docs.txt export-ignore /phpdoc.dist.xml /phpstan* export-ignore /phpunit.xml.dist export-ignore diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index a1c3c832..6c3b51ba 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -1,15 +1,47 @@ -name: Deploy Documentation +name: Documentation + +# The site is built by `make docs`: Zensical renders the guides under docs/ +# (see mkdocs.yml) and phpDocumentor renders the API reference into /api/. +# Pull requests build only — the build is strict, so a broken internal link +# fails CI instead of shipping a dead link to the site. +# +# NOTE: deployment uses the official GitHub Pages actions, so the repository's +# Pages source must be set to "GitHub Actions" (Settings → Pages) instead of +# the gh-pages branch this workflow published to before. on: - release: - types: [published] + push: + branches: [main] + # GitHub Actions does not support YAML anchors, so this list is repeated + # for pull_request below — keep the two in sync. + paths: + - docs/** + - mkdocs.yml + - requirements-docs.txt + - phpdoc.dist.xml + - src/** + - Makefile + - .github/workflows/docs.yml + pull_request: + paths: + - docs/** + - mkdocs.yml + - requirements-docs.txt + - phpdoc.dist.xml + - src/** + - Makefile + - .github/workflows/docs.yml workflow_dispatch: permissions: - contents: write + contents: read + +concurrency: + group: docs-${{ github.ref }} + cancel-in-progress: true jobs: - deploy: + build: runs-on: ubuntu-latest steps: - name: Checkout @@ -21,16 +53,34 @@ jobs: php-version: '8.4' coverage: "none" - - name: Install Composer + - name: Install Composer dependencies uses: "ramsey/composer-install@v4" - - name: Generate Documentation + - name: Install uv + # setup-uv publishes no floating major tag; pin the exact release. + uses: astral-sh/setup-uv@v9.0.0 + with: + enable-cache: true + + - name: Build documentation run: make docs - - name: Deploy to gh-pages branch - uses: peaceiris/actions-gh-pages@v4 + - name: Upload Pages artifact + uses: actions/upload-pages-artifact@v5 with: - github_token: ${{ secrets.GITHUB_TOKEN }} - publish_dir: ./.phpdoc/build - enable_jekyll: false - cname: php.sdk.modelcontextprotocol.io + path: ./site + + deploy: + needs: build + if: github.event_name != 'pull_request' + runs-on: ubuntu-latest + permissions: + pages: write + id-token: write + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v5 diff --git a/.github/workflows/pipeline.yaml b/.github/workflows/pipeline.yaml index b0185431..e9c9e83f 100644 --- a/.github/workflows/pipeline.yaml +++ b/.github/workflows/pipeline.yaml @@ -244,5 +244,7 @@ jobs: - name: PHPStan run: vendor/bin/phpstan analyse + # Only the phpDocumentor half: this job is PHP-only, and the Zensical + # guides are built (strictly) by the Documentation workflow. - name: Documentation - run: make docs + run: make docs-api diff --git a/.gitignore b/.gitignore index c5d87ce7..ca3a13f1 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,7 @@ tests/Conformance/logs/*.log # phpDocumentor .phpdoc/build/ .phpdoc/cache/ + +# Documentation site (make docs) +/site/ +/.cache/ diff --git a/.phpdoc/template/base.html.twig b/.phpdoc/template/base.html.twig index 760f1652..f8feb3c8 100644 --- a/.phpdoc/template/base.html.twig +++ b/.phpdoc/template/base.html.twig @@ -2,7 +2,7 @@ {% set topMenu = { "menu": [ - { "name": "Guides", "url": "docs/index.html"}, + { "name": "Guides", "url": "/"}, { "name": "Specification", "url": "https://modelcontextprotocol.io/" } ], "social": [ diff --git a/.phpdoc/template/components/header-title.html.twig b/.phpdoc/template/components/header-title.html.twig index fe8d091f..ece437cc 100644 --- a/.phpdoc/template/components/header-title.html.twig +++ b/.phpdoc/template/components/header-title.html.twig @@ -1,5 +1,5 @@

- + diff --git a/Makefile b/Makefile index 93fbf1f2..f1d78ef9 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: deps-stable deps-low cs phpstan tests unit-tests integration-tests inspector-tests coverage ci ci-stable ci-lowest conformance-tests conformance-server conformance-client conformance-draft conformance-draft-server conformance-draft-client docs +.PHONY: deps-stable deps-low cs phpstan tests unit-tests integration-tests inspector-tests coverage ci ci-stable ci-lowest conformance-tests conformance-server conformance-client conformance-draft conformance-draft-server conformance-draft-client docs docs-guides docs-api docs-serve # The 2026-07-28 scenarios ship on the `alpha` dist-tag; `latest` (0.1.x) has # none of them. Pinned to the same version CI runs (see @@ -6,6 +6,11 @@ CONFORMANCE_VERSION ?= 0.2.0-alpha.11 CONFORMANCE = npx --yes @modelcontextprotocol/conformance@$(CONFORMANCE_VERSION) +# The documentation toolchain is Python (Zensical, see requirements-docs.txt), +# run through uv so no virtualenv has to be managed by hand: +# https://docs.astral.sh/uv/getting-started/installation/ +DOCS_RUN = uv run --no-project --with-requirements requirements-docs.txt -- + deps-stable: composer update --prefer-stable @@ -73,7 +78,19 @@ ci-stable: deps-stable cs phpstan tests ci-lowest: deps-low cs phpstan tests -docs: - vendor/bin/phpdoc - @grep -q 'No errors have been found' .phpdoc/build/reports/errors.html || \ - (echo "Documentation errors found. See build/docs/reports/errors.html" && exit 1) +# The published site is the guides (Zensical) with the phpDocumentor API +# reference mounted at /api/. `zensical build` wipes site/, so it runs first. +docs: docs-guides docs-api + rm -rf site/api + cp -a .phpdoc/build/api site/api + +docs-guides: + $(DOCS_RUN) zensical build --strict + +docs-api: + vendor/bin/phpdoc --no-interaction + @grep -q 'No errors have been found' .phpdoc/build/api/reports/errors.html || \ + (echo "Documentation errors found. See .phpdoc/build/api/reports/errors.html" && exit 1) + +docs-serve: + $(DOCS_RUN) zensical serve diff --git a/docs/CNAME b/docs/CNAME new file mode 100644 index 00000000..c7735adb --- /dev/null +++ b/docs/CNAME @@ -0,0 +1 @@ +php.sdk.modelcontextprotocol.io diff --git a/docs/authorization.md b/docs/authorization.md index 184eb7e0..fa9ffd5d 100644 --- a/docs/authorization.md +++ b/docs/authorization.md @@ -30,7 +30,7 @@ and it does not issue tokens.** To issue tokens, front the MCP server with an external IdP (Keycloak, Auth0, Microsoft Entra ID, Okta) or run `league/oauth2-server` in your own application, and let the MCP server validate those tokens as a Resource Server. See -[adr/0001-oauth-authorization-server-out-of-scope.md](../adr/0001-oauth-authorization-server-out-of-scope.md). +[adr/0001-oauth-authorization-server-out-of-scope.md](https://github.com/modelcontextprotocol/php-sdk/blob/main/adr/0001-oauth-authorization-server-out-of-scope.md). ## Overview @@ -403,7 +403,7 @@ docker-compose up -d # Test credentials: demo / demo123 ``` -See [oauth-keycloak/README.md](../examples/server/oauth-keycloak/README.md) +See [oauth-keycloak/README.md](https://github.com/modelcontextprotocol/php-sdk/blob/main/examples/server/oauth-keycloak/README.md) ### Microsoft Entra ID Example @@ -414,7 +414,7 @@ cp env.example .env docker-compose up -d ``` -See [oauth-microsoft/README.md](../examples/server/oauth-microsoft/README.md) +See [oauth-microsoft/README.md](https://github.com/modelcontextprotocol/php-sdk/blob/main/examples/server/oauth-microsoft/README.md) ## Security Considerations diff --git a/docs/extensions.md b/docs/extensions.md index aff06aba..f87cf618 100644 --- a/docs/extensions.md +++ b/docs/extensions.md @@ -152,6 +152,6 @@ handshake: See the [`ext-apps` repository][ext-apps] for the full protocol, official TypeScript SDK (`@modelcontextprotocol/ext-apps`), and view-side examples. A working minimal view is included in -[`examples/server/mcp-apps/weather-app.html`](../examples/server/mcp-apps/weather-app.html). +[`examples/server/mcp-apps/weather-app.html`](https://github.com/modelcontextprotocol/php-sdk/blob/main/examples/server/mcp-apps/weather-app.html). [ext-apps]: https://github.com/modelcontextprotocol/ext-apps diff --git a/docs/server-client-communication.md b/docs/server-client-communication.md index 084f51d2..dbb5c9fd 100644 --- a/docs/server-client-communication.md +++ b/docs/server-client-communication.md @@ -11,7 +11,7 @@ MCP supports various ways a server can communicate back to a client on top of th ## Table of Contents -- [ClientGateway](#client-gateway) +- [ClientGateway](#clientgateway) - [Sampling](#sampling) - [Logging](#logging) - [Notification](#notification) diff --git a/mkdocs.yml b/mkdocs.yml new file mode 100644 index 00000000..f6a6d859 --- /dev/null +++ b/mkdocs.yml @@ -0,0 +1,44 @@ +site_name: MCP PHP SDK +site_description: The official PHP SDK for the Model Context Protocol +site_url: https://php.sdk.modelcontextprotocol.io/ +repo_name: modelcontextprotocol/php-sdk +repo_url: https://github.com/modelcontextprotocol/php-sdk +edit_uri: edit/main/docs/ + +# The class-level API reference is still generated by phpDocumentor and copied +# into `site/api/` by `make docs`; Zensical owns everything under `docs/`. +# A relative nav entry would have to resolve to a page in this build, so the +# reference is linked by its absolute URL. +nav: + - MCP PHP SDK: index.md + - MCP Elements: mcp-elements.md + - Server Builder: server-builder.md + - Transports: transports.md + - Client Communication: server-client-communication.md + - Protocol Extensions: extensions.md + - Authorization: authorization.md + - Events: events.md + - Client: client.md + - Examples: examples.md + - API Reference: https://php.sdk.modelcontextprotocol.io/api/ + +theme: + name: material + +# Zensical natively re-implements `search`; it does not run arbitrary MkDocs +# plugins or hooks. +plugins: + - search + +markdown_extensions: + - tables + - admonition + - attr_list + - def_list + - md_in_html + - sane_lists + - pymdownx.details + - pymdownx.inlinehilite + - pymdownx.highlight: + pygments_lang_class: true + - pymdownx.superfences diff --git a/phpdoc.dist.xml b/phpdoc.dist.xml index b209b815..7c705bd1 100644 --- a/phpdoc.dist.xml +++ b/phpdoc.dist.xml @@ -6,16 +6,19 @@ xsi:noNamespaceSchemaLocation="https://docs.phpdoc.org/latest/phpdoc.xsd" > MCP PHP SDK + - .phpdoc/build + .phpdoc/build/api - latest src - api vendor/**/* tests/**/* @@ -31,14 +34,8 @@ implements - - - docs - - / - - + diff --git a/requirements-docs.txt b/requirements-docs.txt new file mode 100644 index 00000000..db75dafb --- /dev/null +++ b/requirements-docs.txt @@ -0,0 +1,8 @@ +# Toolchain for the documentation site under `docs/`, built by `make docs`. +# +# Zensical is the Material for MkDocs team's successor to MkDocs: it reads the +# same `mkdocs.yml` and renders the same Material theme, but resolves internal +# links against the page tree instead of copying markdown through verbatim. +# +# Pinned exactly: Zensical is pre-1.0, so bumps should be deliberate. +zensical==0.0.50 From 8d8b2a3b7c29df5d7f9beb6185726365af14cad9 Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Tue, 11 Aug 2026 00:40:03 +0200 Subject: [PATCH 02/13] [Docs] Adopt the Python SDK's documentation look and feel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The docs site now uses the same theme configuration as https://py.sdk.modelcontextprotocol.io/ so the language SDKs read as one set of docs: the MCP mark as logo and favicon, Inter/JetBrains Mono, the black/slate palette with a three-way (system/light/dark) toggle, instant navigation, code copy/annotate, and a right-hand table of contents. The markdown extension set is widened to the same list (tabbed blocks, task lists, footnotes, emoji/icons, mermaid fences), which the content restructure builds on. Styling is otherwise stock: no custom stylesheet, and Zensical's own `modern` theme variant, pinned explicitly rather than left to the default. The one departure is code highlighting, which is broken out of the box here: Pygments only highlights PHP after a ` diff --git a/docs/favicon.svg b/docs/favicon.svg new file mode 100644 index 00000000..a280d7fd --- /dev/null +++ b/docs/favicon.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/mkdocs.yml b/mkdocs.yml index f6a6d859..b358ebe7 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -22,8 +22,62 @@ nav: - Examples: examples.md - API Reference: https://php.sdk.modelcontextprotocol.io/api/ +# Look and feel is kept in sync with the Python SDK's documentation +# (https://py.sdk.modelcontextprotocol.io/) so the language SDKs read as one +# set of docs: same MCP mark, same black/slate palette, same navigation. theme: name: material + # Zensical's own look, and its default; `classic` renders the older + # Material for MkDocs styling instead. + variant: modern + custom_dir: docs/.overrides + font: + text: Inter + code: JetBrains Mono + icon: + logo: mcp + favicon: favicon.svg + palette: + - media: "(prefers-color-scheme)" + scheme: default + primary: black + accent: black + toggle: + icon: material/lightbulb + name: "Switch to light mode" + - media: "(prefers-color-scheme: light)" + scheme: default + primary: black + accent: black + toggle: + icon: material/lightbulb-outline + name: "Switch to dark mode" + - media: "(prefers-color-scheme: dark)" + scheme: slate + primary: black + accent: white + toggle: + icon: material/lightbulb-auto-outline + name: "Switch to system preference" + features: + - search.suggest + - search.highlight + - content.tabs.link + - content.code.annotate + - content.code.copy + - content.code.select + - navigation.footer + - navigation.indexes + - navigation.instant + - navigation.instant.prefetch + - navigation.instant.progress + - navigation.path + - navigation.prune + - navigation.sections + - navigation.top + - navigation.tracking + - toc.follow + # Zensical natively re-implements `search`; it does not run arbitrary MkDocs # plugins or hooks. @@ -32,13 +86,49 @@ plugins: markdown_extensions: - tables + - abbr - admonition - attr_list - def_list + - footnotes - md_in_html - - sane_lists + - sane_lists # this means you can start a list from any number + - pymdownx.betterem + - pymdownx.caret - pymdownx.details - pymdownx.inlinehilite + - pymdownx.mark + - pymdownx.tilde - pymdownx.highlight: pygments_lang_class: true - - pymdownx.superfences + # Pygments' PHP lexer only starts highlighting after a ` Date: Tue, 11 Aug 2026 00:21:42 +0200 Subject: [PATCH 03/13] [Docs] Restructure the guides into task-oriented sections MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The guides were ten flat pages, each opening with a hand-maintained table of contents and each mixing several audiences: `mcp-elements.md` covered tools, prompts, schema generation and handler-side logging, `transports.md` covered both transports plus framework integration, and `server-builder.md` covered configuration, sessions and custom message handlers. They are now split along the same lines as the Python SDK's documentation (https://py.sdk.modelcontextprotocol.io/), one topic per page: Get started installation, first server, the Inspector Servers tools, resources, resource templates, prompts, completions, schema generation, registration Inside your handler the ClientGateway, logging Running your server builder, STDIO, HTTP, framework integration, sessions, authorization Clients connecting, transports, capabilities, server-initiated requests, error handling Advanced events, protocol extensions, custom message handlers Prose is carried over as-is apart from the seams; what is new is the landing page and one index page per section, which say what the section is for and where to go next, so no page is a dead end. The per-page "Table of Contents" lists are gone — the theme renders one from the headings — and GitHub's `> [!IMPORTANT]` blockquotes became admonitions, which Zensical renders as callouts rather than plain quotes. Every code sample and factual claim was then checked against src/ and the runnable examples, which turned up long-standing errors in the carried-over prose. Samples that could not run: a prompt using a `system` role (MCP has only user/assistant), `Mcp\Schema\PromptMessage` (it is under `Schema\Content`) constructed with an array instead of a single content object, `Mcp\Capability\Prompt\Completion\ProviderInterface` (it is `Mcp\Capability\Completion\ProviderInterface`), `new EmbeddedResource(type:, resource: [...])` (neither parameter exists), a `: resource` return type (not a PHP type), `#[McpResource]` with a `{path}` variable (that is a template), a stray quote in the builder example, a `middlewares:` argument (it is `middleware:`), and `getRequest()->getAttribute()` in the OAuth guide (no such method — the values arrive on the request meta). Claims corrected: sampling's `system_prompt` option (it is `systemPrompt`), `SampleMessage` (it is `SamplingMessage`), `Notification` called an interface (abstract class), "parameter order matters" for URI templates (bound by name), full RFC 6570 support (only simple `{var}`), the tool description fallback chain, `void` returning empty content, a non-zero STDIO exit code, `ErrorEvent` being null for parse errors, handlers being "prepended", `Psr16StoreSession`, and PSR-3 log context being sent to the client (it is dropped). Sixteen `examples/` paths were missing their `server/` segment. README, the OAuth ADR and one source comment now point at the published site instead of at markdown files that moved. --- README.md | 23 +- ...oauth-authorization-server-out-of-scope.md | 2 +- docs/advanced/custom-handlers.md | 97 ++ docs/{ => advanced}/events.md | 13 +- docs/{ => advanced}/extensions.md | 0 docs/advanced/index.md | 10 + docs/client.md | 879 ----------------- docs/client/capabilities.md | 157 +++ docs/client/connecting.md | 181 ++++ docs/client/errors.md | 155 +++ docs/client/index.md | 39 + docs/client/server-requests.md | 169 ++++ docs/client/transports.md | 59 ++ docs/examples.md | 52 +- docs/get-started/first-server.md | 84 ++ docs/get-started/index.md | 27 + docs/get-started/inspector.md | 88 ++ docs/get-started/installation.md | 44 + .../client-communication.md} | 32 +- docs/handlers/index.md | 32 + docs/handlers/logging.md | 31 + docs/index.md | 112 ++- docs/mcp-elements.md | 892 ------------------ docs/{ => run}/authorization.md | 28 +- docs/run/framework-integration.md | 202 ++++ docs/{transports.md => run/http.md} | 332 +------ docs/run/index.md | 35 + docs/run/server-builder.md | 262 +++++ docs/run/sessions.md | 122 +++ docs/run/stdio.md | 62 ++ docs/server-builder.md | 747 --------------- docs/servers/completions.md | 98 ++ docs/servers/index.md | 30 + docs/servers/prompts.md | 130 +++ docs/servers/registration.md | 242 +++++ docs/servers/resource-templates.md | 48 + docs/servers/resources.md | 151 +++ docs/servers/schemas.md | 111 +++ docs/servers/tools.md | 148 +++ examples/client/http_discovery_calculator.php | 2 +- mkdocs.yml | 46 +- .../ProtectedResourceMetadataHandler.php | 2 +- 42 files changed, 3029 insertions(+), 2947 deletions(-) create mode 100644 docs/advanced/custom-handlers.md rename docs/{ => advanced}/events.md (91%) rename docs/{ => advanced}/extensions.md (100%) create mode 100644 docs/advanced/index.md delete mode 100644 docs/client.md create mode 100644 docs/client/capabilities.md create mode 100644 docs/client/connecting.md create mode 100644 docs/client/errors.md create mode 100644 docs/client/index.md create mode 100644 docs/client/server-requests.md create mode 100644 docs/client/transports.md create mode 100644 docs/get-started/first-server.md create mode 100644 docs/get-started/index.md create mode 100644 docs/get-started/inspector.md create mode 100644 docs/get-started/installation.md rename docs/{server-client-communication.md => handlers/client-communication.md} (71%) create mode 100644 docs/handlers/index.md create mode 100644 docs/handlers/logging.md delete mode 100644 docs/mcp-elements.md rename docs/{ => run}/authorization.md (95%) create mode 100644 docs/run/framework-integration.md rename docs/{transports.md => run/http.md} (50%) create mode 100644 docs/run/index.md create mode 100644 docs/run/server-builder.md create mode 100644 docs/run/sessions.md create mode 100644 docs/run/stdio.md delete mode 100644 docs/server-builder.md create mode 100644 docs/servers/completions.md create mode 100644 docs/servers/index.md create mode 100644 docs/servers/prompts.md create mode 100644 docs/servers/registration.md create mode 100644 docs/servers/resource-templates.md create mode 100644 docs/servers/resources.md create mode 100644 docs/servers/schemas.md create mode 100644 docs/servers/tools.md diff --git a/README.md b/README.md index 235d3f51..18b4d552 100644 --- a/README.md +++ b/README.md @@ -170,7 +170,7 @@ $server = Server::builder() ->build(); ``` -[→ Server Documentation](docs/server-builder.md) +[→ Server Documentation](https://php.sdk.modelcontextprotocol.io/run/server-builder/) ## Client SDK @@ -284,24 +284,25 @@ $transport = new HttpTransport('http://localhost:8000'); $client->connect($transport); ``` -[→ Client Documentation](docs/client.md) +[→ Client Documentation](https://php.sdk.modelcontextprotocol.io/client/) ## Documentation +The full documentation is published at **[php.sdk.modelcontextprotocol.io](https://php.sdk.modelcontextprotocol.io/)**. + ### Core Concepts -- **[Server Builder](docs/server-builder.md)** — Complete ServerBuilder reference and configuration -- **[Client](docs/client.md)** — Client SDK for connecting to and communicating with MCP servers -- **[Transports](docs/transports.md)** — STDIO and HTTP transport setup and usage -- **[MCP Elements](docs/mcp-elements.md)** — Creating tools, resources, prompts, and templates -- **[Server-Client Communication](docs/server-client-communication.md)** — Sampling, logging, progress, and notifications -- **[Protocol Extensions](docs/extensions.md)** — Opt-in protocol extensions announced during capability negotiation, including MCP Apps (HTML UI resources) -- **[Authorization](docs/authorization.md)** — OAuth and authorization setup for HTTP transport -- **[Events](docs/events.md)** — Hooking into server lifecycle with events +- **[Get started](https://php.sdk.modelcontextprotocol.io/get-started/)** — Install the SDK and build your first server +- **[Servers](https://php.sdk.modelcontextprotocol.io/servers/)** — Tools, resources, resource templates, prompts, and how to register them +- **[Inside your handler](https://php.sdk.modelcontextprotocol.io/handlers/)** — Sampling, logging, progress, and notifications from within a handler +- **[Running your server](https://php.sdk.modelcontextprotocol.io/run/)** — Server builder, STDIO and HTTP transports, framework integration, sessions, authorization +- **[Clients](https://php.sdk.modelcontextprotocol.io/client/)** — Client SDK for connecting to and communicating with MCP servers +- **[Advanced](https://php.sdk.modelcontextprotocol.io/advanced/)** — Events, protocol extensions (including MCP Apps), and custom message handlers +- **[API Reference](https://php.sdk.modelcontextprotocol.io/api/)** — Generated class reference ### Learning & Examples -- **[Examples](docs/examples.md)** — Comprehensive example walkthroughs for servers and clients +- **[Examples](https://php.sdk.modelcontextprotocol.io/examples/)** — Comprehensive example walkthroughs for servers and clients - **[ROADMAP.md](ROADMAP.md)** — Planned features and development roadmap ## External Resources diff --git a/adr/0001-oauth-authorization-server-out-of-scope.md b/adr/0001-oauth-authorization-server-out-of-scope.md index fa5b091f..ebb5947c 100644 --- a/adr/0001-oauth-authorization-server-out-of-scope.md +++ b/adr/0001-oauth-authorization-server-out-of-scope.md @@ -93,5 +93,5 @@ If you need an authorization server (token issuance, client registration, login, validator seams. The MCP server validates the tokens it issues; it does not issue them itself. -See [`../docs/authorization.md`](../docs/authorization.md) for the supported Resource Server +See [`../docs/run/authorization.md`](../docs/run/authorization.md) for the supported Resource Server and delegation setup. diff --git a/docs/advanced/custom-handlers.md b/docs/advanced/custom-handlers.md new file mode 100644 index 00000000..68a0ed04 --- /dev/null +++ b/docs/advanced/custom-handlers.md @@ -0,0 +1,97 @@ +# Custom Message Handlers + +**Low-level escape hatch.** Custom message handlers run before the SDK's built-in handlers and give you total control over +individual JSON-RPC messages. They do not receive the builder's registry, container, or discovery output unless you pass +those dependencies in yourself. + +> **Warning**: Custom message handlers bypass discovery, manual capability registration, and container lookups (unless +> you explicitly pass them). Tools, resources, and prompts you register elsewhere will not show up unless your handler +> loads and executes them manually. Reach for this API only when you need that level of control and are comfortable +> taking on the additional plumbing. + +## Request Handlers + +Handle JSON-RPC requests (messages with an `id` that expect a response). Request handlers **must** return either a +`Response` or an `Error` object. + +Attach request handlers with `addRequestHandler()` (single) or `addRequestHandlers()` (multiple). You can call these +methods as many times as needed; each call prepends the handlers so they execute before the defaults: + +```php +$server = Server::builder() + ->addRequestHandler(new CustomListToolsHandler()) + ->addRequestHandlers([ + new CustomCallToolHandler(), + new CustomGetPromptHandler(), + ]) + ->build(); +``` + +Request handlers implement `RequestHandlerInterface`: + +```php +use Mcp\Schema\JsonRpc\Error; +use Mcp\Schema\JsonRpc\Request; +use Mcp\Schema\JsonRpc\Response; +use Mcp\Server\Handler\Request\RequestHandlerInterface; +use Mcp\Server\Session\SessionInterface; + +interface RequestHandlerInterface +{ + public function supports(Request $request): bool; + + public function handle(Request $request, SessionInterface $session): Response|Error; +} +``` + +- `supports()` decides if the handler should process the incoming request +- `handle()` **must** return a `Response` (on success) or an `Error` (on failure) + +## Notification Handlers + +Handle JSON-RPC notifications (messages without an `id` that don't expect a response). Notification handlers **do not** +return anything - they perform side effects only. + +Attach notification handlers with `addNotificationHandler()` (single) or `addNotificationHandlers()` (multiple): + +```php +// Handlers are your own classes implementing NotificationHandlerInterface; +// the SDK ships only its internal ones. +$server = Server::builder() + ->addNotificationHandler(new AuditNotificationHandler($auditLog)) + ->addNotificationHandlers([ + new MetricsNotificationHandler($metrics), + new CancellationNotificationHandler(), + ]) + ->build(); +``` + +Notification handlers implement `NotificationHandlerInterface`: + +```php +use Mcp\Schema\JsonRpc\Notification; +use Mcp\Server\Handler\Notification\NotificationHandlerInterface; +use Mcp\Server\Session\SessionInterface; + +interface NotificationHandlerInterface +{ + public function supports(Notification $notification): bool; + + public function handle(Notification $notification, SessionInterface $session): void; +} +``` + +- `supports()` decides if the handler should process the incoming notification +- `handle()` performs side effects but **does not** return a value (notifications have no response) + +## Key Differences + +| Handler Type | Interface | Returns | Use Case | +|-------------|-----------|---------|----------| +| Request Handler | `RequestHandlerInterface` | `Response\|Error` | Handle requests that need responses (e.g., `tools/list`, `tools/call`) | +| Notification Handler | `NotificationHandlerInterface` | `void` | Handle fire-and-forget notifications (e.g., `notifications/initialized`, `notifications/progress`) | + +## Example + +Check out `examples/server/custom-method-handlers/server.php` for a complete example showing how to implement +custom `tools/list` and `tools/call` request handlers independently of the registry. diff --git a/docs/events.md b/docs/advanced/events.md similarity index 91% rename from docs/events.md rename to docs/advanced/events.md index ebd70ed2..590ad6ea 100644 --- a/docs/events.md +++ b/docs/advanced/events.md @@ -2,21 +2,12 @@ The MCP SDK provides a PSR-14 compatible event system that allows you to hook into the server's lifecycle. Events enable request/response modification, and other user-defined behaviors. -## Table of Contents - -- [Setup](#setup) -- [Protocol Events](#protocol-events) - - [RequestEvent](#requestevent) - - [ResponseEvent](#responseevent) - - [ErrorEvent](#errorevent) - - [NotificationEvent](#notificationevent) -- [List Change Events](#list-change-events) - ## Setup Configure an event dispatcher when building your server: ```php +use Mcp\Event\RequestEvent; use Mcp\Server; use Symfony\Component\EventDispatcher\EventDispatcher; @@ -67,7 +58,7 @@ The SDK dispatches 4 broad event types at the protocol level, allowing you to ob **Properties**: - `getError(): Error` - The error being sent - `setError(Error $error): void` - Modify the error before sending -- `getRequest(): Request` - The original request (null for parse errors) +- `getRequest(): Request` - The original request. Messages that fail to parse are rejected before this event, so a listener never sees them. - `getThrowable(): ?\Throwable` - The exception that caused the error (if any) - `getSession(): SessionInterface` - The current session diff --git a/docs/extensions.md b/docs/advanced/extensions.md similarity index 100% rename from docs/extensions.md rename to docs/advanced/extensions.md diff --git a/docs/advanced/index.md b/docs/advanced/index.md new file mode 100644 index 00000000..2a79a3ce --- /dev/null +++ b/docs/advanced/index.md @@ -0,0 +1,10 @@ +# Advanced + +Everything here is optional. A working server needs none of it. + +* **[Events](events.md)** — PSR-14 events dispatched around every request, response, + error, and notification. Useful for metrics, audit logs, and debugging. +* **[Protocol extensions](extensions.md)** — opt-in extensions announced during + capability negotiation, including MCP Apps (HTML UI resources). +* **[Custom message handlers](custom-handlers.md)** — taking over a JSON-RPC method the + SDK does not implement, or overriding one it does. diff --git a/docs/client.md b/docs/client.md deleted file mode 100644 index f17b8555..00000000 --- a/docs/client.md +++ /dev/null @@ -1,879 +0,0 @@ -# Client - -The MCP Client SDK provides a synchronous, framework-agnostic API for communicating with MCP servers from PHP applications. -It handles connection management, request/response correlation, server-initiated requests (sampling), and real-time notifications. - -## Table of Contents - -- [Overview](#overview) -- [Client Builder](#client-builder) -- [Transports](#transports) -- [Connecting to Servers](#connecting-to-servers) -- [Server Information](#server-information) -- [Working with Tools](#working-with-tools) -- [Working with Resources](#working-with-resources) -- [Working with Prompts](#working-with-prompts) -- [Server-Initiated Communication](#server-initiated-communication) -- [Error Handling](#error-handling) -- [Complete Example](#complete-example) - -## Overview - -The client follows a builder pattern for configuration and provides a synchronous API for all operations: - -```php -use Mcp\Client; -use Mcp\Client\Transport\StdioTransport; - -// Build and configure the client -$client = Client::builder() - ->setClientInfo('My Client', '1.0.0') - ->setInitTimeout(30) - ->setRequestTimeout(120) - ->build(); - -// Create a transport -$transport = new StdioTransport( - command: 'php', - args: ['/path/to/server.php'], -); - -// Connect and use the server -$client->connect($transport); -$tools = $client->listTools(); -$client->disconnect(); -``` - -## Client Builder - -The `Client\Builder` provides fluent configuration of client instances. - -### Basic Configuration - -```php -use Mcp\Client; - -$client = Client::builder() - ->setClientInfo('My Application', '1.0.0', 'Description of my client') - ->setInitTimeout(30) // Seconds to wait for initialization - ->setRequestTimeout(120) // Seconds to wait for request responses - ->setMaxRetries(3) // Retries for failed connections - ->build(); -``` - -### Connection Retries - -`setMaxRetries()` controls how often `connect()` retries a failed connection. It -counts retries rather than attempts, so the default of `3` means one initial -attempt plus up to three retries — four in total — before the `ConnectionException` -of the last attempt is rethrown: - -```php -$client = Client::builder() - ->setMaxRetries(0) // Fail on the first failed attempt - ->build(); -``` - -Between two attempts the transport is closed, so a retry never reuses a -half-established connection: a `StdioTransport` spawns a fresh server process and -an `HttpTransport` discards the session ID of the failed attempt. Each retry is -preceded by a short, linearly growing delay (100ms, 200ms, 300ms, …). - -Only the connection handshake is retried. Individual requests such as -`callTool()` are always sent once — retrying them is unsafe as tool calls are not -necessarily idempotent. - -### Client Information - -Set the client's identity reported to servers during initialization: - -```php -$client = Client::builder() - ->setClientInfo( - name: 'AI Assistant Client', - version: '2.1.0', - description: 'Client for automated AI workflows' - ) - ->build(); -``` - -### Protocol Version - -Specify the MCP protocol version to offer during the handshake (defaults to the latest): - -```php -use Mcp\Schema\Enum\ProtocolVersion; - -$client = Client::builder() - ->setProtocolVersion(ProtocolVersion::V2025_11_25) - ->build(); -``` - -This is an offer, not a demand. A server that does not support the requested revision counter-offers one it does, as -described in the specification's -[protocol version negotiation](https://modelcontextprotocol.io/specification/draft/basic/versioning#protocol-version-negotiation) -section. The client accepts any counter-offer it knows about and continues on that revision; a counter-offer the SDK -cannot speak fails the handshake with a `ConnectionException` rather than continuing on a revision neither side agreed -on. Use `$client->getProtocolVersion()` after connecting to read what was actually negotiated. - -Modern revisions such as `2026-07-28` replaced `initialize` with per-request metadata, so they cannot be offered here. -Configuring one still opens the handshake with `ProtocolVersion::latestHandshake()`, and the client logs a warning -saying so. - -See [Protocol Version Negotiation](server-builder.md#protocol-version-negotiation) for the server side of the exchange. - -### Capabilities - -Declare client capabilities to enable server features: - -```php -use Mcp\Schema\ClientCapabilities; - -$client = Client::builder() - ->setCapabilities(new ClientCapabilities( - sampling: true, // Enable LLM sampling requests from server - roots: true, // Enable filesystem root listing - )) - ->build(); -``` - -### Notification Handlers - -Register handlers for server-initiated notifications: - -```php -use Mcp\Client\Handler\Notification\LoggingNotificationHandler; -use Mcp\Schema\Notification\LoggingMessageNotification; - -$loggingHandler = new LoggingNotificationHandler( - static function (LoggingMessageNotification $notification) { - echo "[{$notification->level->value}] {$notification->data}\n"; - } -); - -$client = Client::builder() - ->addNotificationHandler($loggingHandler) - ->build(); -``` - -### Request Handlers - -Register handlers for server-initiated requests (e.g., sampling): - -```php -use Mcp\Client\Handler\Request\SamplingRequestHandler; -use Mcp\Client\Handler\Request\SamplingCallbackInterface; -use Mcp\Schema\Request\CreateSamplingMessageRequest; -use Mcp\Schema\Result\CreateSamplingMessageResult; - -$samplingCallback = new class implements SamplingCallbackInterface { - public function __invoke(CreateSamplingMessageRequest $request): CreateSamplingMessageResult - { - // Perform LLM sampling and return result - } -}; - -$client = Client::builder() - ->addRequestHandler(new SamplingRequestHandler($samplingCallback)) - ->build(); -``` - -### Logger - -Configure PSR-3 logging for debugging: - -```php -use Monolog\Logger; -use Monolog\Handler\StreamHandler; - -$logger = new Logger('mcp-client'); -$logger->pushHandler(new StreamHandler('client.log', Logger::DEBUG)); - -$client = Client::builder() - ->setLogger($logger) - ->build(); -``` - -## Transports - -Transports handle the communication layer between client and server. - -### STDIO Transport - -Spawns a server process and communicates via standard input/output: - -```php -use Mcp\Client\Transport\StdioTransport; - -$transport = new StdioTransport( - command: 'php', - args: ['/path/to/server.php'], - cwd: '/working/directory', // Optional working directory - env: ['KEY' => 'value'], // Optional environment variables -); -``` - -**Parameters:** -- `command` (string): The command to execute -- `args` (array): Command arguments -- `cwd` (string|null): Working directory for the process -- `env` (array|null): Environment variables -- `logger` (LoggerInterface|null): Optional PSR-3 logger - -### HTTP Transport - -Communicates with remote MCP servers over HTTP: - -```php -use Mcp\Client\Transport\HttpTransport; - -$transport = new HttpTransport( - endpoint: 'http://localhost:8000', - headers: ['Authorization' => 'Bearer token'], -); -``` - -**Parameters:** -- `endpoint` (string): The MCP server URL -- `headers` (array): Additional HTTP headers -- `httpClient` (ClientInterface|null): PSR-18 HTTP client (auto-discovered) -- `requestFactory` (RequestFactoryInterface|null): PSR-17 request factory (auto-discovered) -- `streamFactory` (StreamFactoryInterface|null): PSR-17 stream factory (auto-discovered) -- `logger` (LoggerInterface|null): Optional PSR-3 logger - -**PSR-18 Auto-Discovery:** - -The transport automatically discovers PSR-18 HTTP clients from: -- `php-http/guzzle7-adapter` -- `php-http/curl-client` -- `symfony/http-client` -- And other PSR-18 compatible implementations - -```bash -# Install any PSR-18 client - discovery works automatically -composer require php-http/guzzle7-adapter -``` - - -## Connecting to Servers - -### Establishing Connection - -```php -$client->connect($transport); -``` - -The `connect()` method performs the MCP initialization handshake: -1. Opens the transport connection -2. Sends InitializeRequest with client capabilities -3. Waits for InitializeResult from server -4. Sends InitializedNotification - -> [!IMPORTANT] -> Always wrap connection in try/catch to handle `ConnectionException` for failed connections. - -### Checking Connection State - -```php -if ($client->isConnected()) { - // Client is connected and initialized -} -``` - -### Disconnecting - -```php -$client->disconnect(); -``` - -Always disconnect when finished to clean up resources: - -```php -try { - $client->connect($transport); - // ... use the client ... -} finally { - $client->disconnect(); -} -``` - -## Server Information - -After successful connection, retrieve server metadata: - -```php -// Get server implementation info -$serverInfo = $client->getServerInfo(); -echo "Server: {$serverInfo->name} v{$serverInfo->version}\n"; - -// Get server instructions -$instructions = $client->getInstructions(); -if ($instructions) { - echo "Instructions: {$instructions}\n"; -} -``` - -## Working with Tools - -### Listing Tools - -```php -$toolsResult = $client->listTools(); - -foreach ($toolsResult->tools as $tool) { - echo "- {$tool->name}: {$tool->description}\n"; -} - -// Handle pagination -if ($toolsResult->nextCursor) { - $moreTools = $client->listTools($toolsResult->nextCursor); -} -``` - -### Calling Tools - -```php -$result = $client->callTool( - name: 'calculate', - arguments: ['a' => 5, 'b' => 3, 'operation' => 'add'], -); - -// Access results -foreach ($result->content as $content) { - if ($content instanceof TextContent) { - echo $content->text; - } -} -``` - -### Progress Notifications - -Hook into tool execution progress (if server supports it): - -```php -$result = $client->callTool( - name: 'long_running_task', - arguments: ['data' => 'large_dataset'], - onProgress: static function (float $progress, ?float $total, ?string $message) { - $percent = $total > 0 ? round(($progress / $total) * 100) : 0; - echo "Progress: {$percent}% - {$message}\n"; - } -); -``` - -> [!NOTE] -> Progress notifications are only received if the server sends them. The callback will not be invoked if the server doesn't support or send progress updates. - -## Working with Resources - -### Listing Resources - -```php -$resourcesResult = $client->listResources(); - -foreach ($resourcesResult->resources as $resource) { - echo "- {$resource->uri}: {$resource->name}\n"; -} -``` - -### Listing Resource Templates - -```php -$templatesResult = $client->listResourceTemplates(); - -foreach ($templatesResult->resourceTemplates as $template) { - echo "- {$template->uriTemplate}: {$template->name}\n"; -} -``` - -### Reading Resources - -```php -$resourceResult = $client->readResource('config://app/settings'); - -foreach ($resourceResult->contents as $content) { - if ($content instanceof TextResourceContents) { - echo "Text: {$content->text}\n"; - } elseif ($content instanceof BlobResourceContents) { - echo "Binary data (base64): {$content->blob}\n"; - } -} -``` - -Resources also support progress notifications: - -```php -$result = $client->readResource( - uri: 'file://large-file.bin', - onProgress: static function (float $progress, ?float $total, ?string $message) { - echo "Reading: {$progress}/{$total} bytes\n"; - } -); -``` - -## Working with Prompts - -### Listing Prompts - -```php -$promptsResult = $client->listPrompts(); - -foreach ($promptsResult->prompts as $prompt) { - echo "- {$prompt->name}: {$prompt->description}\n"; -} -``` - -### Getting Prompts - -```php -$promptResult = $client->getPrompt( - name: 'code_review', - arguments: ['language' => 'php', 'code' => '...'], -); - -foreach ($promptResult->messages as $message) { - echo "{$message->role->value}: {$message->content->text}\n"; -} -``` - -Prompts also support progress notifications: - -```php -$result = $client->getPrompt( - name: 'generate_report', - arguments: ['topic' => 'quarterly_analysis'], - onProgress: static function (float $progress, ?float $total, ?string $message) { - echo "Generating: {$message}\n"; - } -); -``` - -### Requesting Completions - -Request auto-completion suggestions for prompt or resource arguments: - -```php -use Mcp\Schema\PromptReference; - -$completionResult = $client->complete( - ref: new PromptReference('code_review'), - argument: ['name' => 'language', 'value' => 'ph'], -); - -foreach ($completionResult->values as $value) { - echo "Suggestion: {$value}\n"; -} -``` - -## Server-Initiated Communication - -The client can receive requests and notifications from the server when configured with appropriate handlers. - -### Logging Notifications - -> **Deprecated** since protocol revision `2026-07-28` ([SEP-2577](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577)), earliest removal `2027-07-28`. Logging keeps working until then; new integrations should log to stderr (stdio) or use OpenTelemetry instead. - -Receive structured log messages from the server: - -```php -use Mcp\Client\Handler\Notification\LoggingNotificationHandler; -use Mcp\Schema\Notification\LoggingMessageNotification; -use Mcp\Schema\Enum\LoggingLevel; - -$loggingHandler = new LoggingNotificationHandler( - static function (LoggingMessageNotification $notification) { - // Route to your application's logging system - $level = $notification->level; - $message = $notification->data; - - match ($level) { - LoggingLevel::Debug => logger()->debug($message), - LoggingLevel::Info => logger()->info($message), - LoggingLevel::Warning => logger()->warning($message), - LoggingLevel::Error => logger()->error($message), - default => logger()->info($message), - }; - } -); - -$client = Client::builder() - ->addNotificationHandler($loggingHandler) - ->build(); - -// Set minimum log level (optional) -$client->setLoggingLevel(LoggingLevel::Info); -``` - -### Sampling (LLM Requests) - -> **Deprecated** since protocol revision `2026-07-28` ([SEP-2577](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577)), earliest removal `2027-07-28`. Sampling keeps working until then; new integrations should call an LLM provider's API directly instead. - -Handle server requests for LLM completions: - -```php -use Mcp\Client\Handler\Request\SamplingRequestHandler; -use Mcp\Client\Handler\Request\SamplingCallbackInterface; -use Mcp\Exception\SamplingException; -use Mcp\Schema\ClientCapabilities; -use Mcp\Schema\Request\CreateSamplingMessageRequest; -use Mcp\Schema\Result\CreateSamplingMessageResult; -use Mcp\Schema\Content\TextContent; -use Mcp\Schema\Enum\Role; - -class LlmSamplingCallback implements SamplingCallbackInterface -{ - public function __invoke(CreateSamplingMessageRequest $request): CreateSamplingMessageResult - { - try { - // Call your LLM provider - $response = $this->llmClient->complete( - messages: $request->messages, - maxTokens: $request->maxTokens, - temperature: $request->temperature ?? 0.7, - ); - - return new CreateSamplingMessageResult( - role: Role::Assistant, - content: new TextContent($response->text), - model: $response->model, - stopReason: $response->stopReason, - ); - } catch (\Throwable $e) { - // Throw SamplingException to surface error to server - throw new SamplingException( - "LLM sampling failed: {$e->getMessage()}", - (int) $e->getCode(), - $e - ); - } - } -} - -$client = Client::builder() - ->setCapabilities(new ClientCapabilities(sampling: true)) - ->addRequestHandler(new SamplingRequestHandler(new LlmSamplingCallback)) - ->build(); -``` - -#### Sampling with Tools - -Clients that support tool-enabled sampling should advertise that capability and forward the request's `tools` and -`toolChoice` fields to their LLM provider. A provider response that requests tools can be returned as one or more -`ToolUseContent` blocks: - -```php -use Mcp\Schema\ClientCapabilities; -use Mcp\Schema\Content\ToolUseContent; -use Mcp\Schema\Enum\Role; -use Mcp\Schema\Result\CreateSamplingMessageResult; - -$client = Client::builder() - ->setCapabilities(new ClientCapabilities( - sampling: true, - samplingContext: true, - samplingTools: true, - )) - ->addRequestHandler(new SamplingRequestHandler($samplingCallback)) - ->build(); - -// Inside the sampling callback, after invoking the LLM provider: -return new CreateSamplingMessageResult( - role: Role::Assistant, - content: array_map( - static fn ($call) => new ToolUseContent($call->id, $call->name, $call->input), - $providerResponse->toolCalls, - ), - model: $providerResponse->model, - stopReason: 'toolUse', -); -``` - -The server executes the requested tools and sends their results in a later sampling request as `ToolResultContent` -blocks in a user message. The client should pass those blocks back to the LLM provider to continue the sampling loop. - -> [!IMPORTANT] -> **Error Handling in Sampling Callbacks:** -> -> When implementing sampling callbacks, error handling is critical: -> -> - **Throw `SamplingException`** to forward specific error messages to the server -> - **Any other exception** will be logged but return a generic error to the server -> -> This distinction allows you to control what error information the server receives: -> -> ```php -> // Good: Server receives "Rate limit exceeded" message -> throw new SamplingException('Rate limit exceeded. Retry after 60 seconds.'); -> -> // Bad: Server receives generic "Error while sampling LLM" message -> throw new \RuntimeException('Rate limit exceeded'); -> ``` - -### Elicitation (User Input Requests) - -Handle server requests to elicit additional information from the user during tool -execution. The server sends an `elicitation/create` request describing the fields it -needs; your callback presents them to the user and returns an `ElicitResult` with one of -three actions — accept (with the collected content), decline, or cancel: - -```php -use Mcp\Client\Handler\Request\ElicitationRequestHandler; -use Mcp\Client\Handler\Request\ElicitationCallbackInterface; -use Mcp\Exception\ElicitationException; -use Mcp\Schema\ClientCapabilities; -use Mcp\Schema\Enum\ElicitAction; -use Mcp\Schema\Request\ElicitRequest; -use Mcp\Schema\Result\ElicitResult; - -class ConsoleElicitationCallback implements ElicitationCallbackInterface -{ - public function __invoke(ElicitRequest $request): ElicitResult - { - echo $request->message.\PHP_EOL; - - // Present $request->requestedSchema->properties to the user and collect input. - $content = []; - foreach ($request->requestedSchema->properties as $name => $definition) { - $answer = readline($definition->title.': '); - - if (false === $answer) { - // No input available — let the server know the user cancelled. - return new ElicitResult(ElicitAction::Cancel); - } - - $content[$name] = $answer; - } - - return new ElicitResult(ElicitAction::Accept, $content); - } -} - -$client = Client::builder() - ->setCapabilities(new ClientCapabilities(elicitation: true)) - ->addRequestHandler(new ElicitationRequestHandler(new ConsoleElicitationCallback)) - ->build(); -``` - -Return `new ElicitResult(ElicitAction::Decline)` when the user refuses to provide the -information, and `new ElicitResult(ElicitAction::Cancel)` when they dismiss the request. -Only the `Accept` action carries content. - -> [!IMPORTANT] -> **Error Handling in Elicitation Callbacks:** -> -> - **Throw `ElicitationException`** to forward a specific error message to the server -> - **Any other exception** is logged but returns a generic error to the server -> -> ```php -> // Good: Server receives "No interactive console available" message -> throw new ElicitationException('No interactive console available'); -> -> // Bad: Server receives generic "Error while processing elicitation" message -> throw new \RuntimeException('No interactive console available'); -> ``` - -See `examples/client/stdio_elicitation.php` for a runnable example against the -elicitation demo server. - -### Roots - -> **Deprecated** since protocol revision `2026-07-28` ([SEP-2577](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577)), earliest removal `2027-07-28`. Roots keep working until then; new integrations should pass directories or files through tool arguments, resource URIs or server configuration instead. - -Roots let the client expose a list of `file://` "workspace folders" that the server -is allowed to operate on. Advertise the `roots` capability and register a handler -that answers server `roots/list` requests: - -```php -use Mcp\Client\Handler\Request\ListRootsRequestHandler; -use Mcp\Client\Handler\Request\RootsCallbackInterface; -use Mcp\Schema\ClientCapabilities; -use Mcp\Schema\Request\ListRootsRequest; -use Mcp\Schema\Result\ListRootsResult; -use Mcp\Schema\Root; - -class WorkspaceRootsCallback implements RootsCallbackInterface -{ - public function __invoke(ListRootsRequest $request): ListRootsResult - { - return new ListRootsResult([ - new Root('file:///home/user/projects/app', 'Application'), - new Root('file:///home/user/projects/library', 'Library'), - ]); - } -} - -$client = Client::builder() - ->setCapabilities(new ClientCapabilities(roots: true, rootsListChanged: true)) - ->addRequestHandler(new ListRootsRequestHandler(new WorkspaceRootsCallback)) - ->build(); -``` - -When the client's roots change, notify the server so it can request the updated -list via `roots/list`. This requires advertising the `roots.listChanged` -capability (`rootsListChanged: true` above); otherwise `sendRootsListChanged()` -throws a `RuntimeException`. On a client that is not connected it throws a -`ConnectionException`: - -```php -$client->sendRootsListChanged(); -``` - -See `examples/client/stdio_roots.php` for a runnable example: it calls the -`inspect_workspace_roots` tool of the client-communication demo server, which -answers by issuing the `roots/list` request back to the client. - -## Error Handling - -The client throws exceptions for various error conditions: - -### ConnectionException - -Thrown when connection or initialization fails: - -```php -use Mcp\Exception\ConnectionException; - -try { - $client->connect($transport); -} catch (ConnectionException $e) { - echo "Failed to connect: {$e->getMessage()}\n"; -} -``` - -### RequestException - -Thrown when a request returns an error response: - -```php -use Mcp\Exception\RequestException; - -try { - $result = $client->callTool('unknown_tool', []); -} catch (RequestException $e) { - echo "Request failed: {$e->getMessage()}\n"; - echo "Error code: {$e->getCode()}\n"; -} -``` - -## Complete Example - -Here's a comprehensive example demonstrating client usage: - -```php -level->value}] {$notification->data}\n"; - } -); - -// Configure sampling callback -$samplingCallback = new class implements SamplingCallbackInterface { - public function __invoke(CreateSamplingMessageRequest $request): CreateSamplingMessageResult - { - echo "[SAMPLING] Processing request (max {$request->maxTokens} tokens)\n"; - - try { - // Integration with your LLM provider - $response = "This is a mock LLM response for: " . - json_encode($request->messages); - - return new CreateSamplingMessageResult( - role: Role::Assistant, - content: new TextContent($response), - model: 'mock-llm', - stopReason: 'endTurn', - ); - } catch (\Throwable $e) { - throw new SamplingException( - "Sampling failed: {$e->getMessage()}", - 0, - $e - ); - } - } -}; - -// Build client -$client = Client::builder() - ->setClientInfo('Example Client', '1.0.0') - ->setInitTimeout(30) - ->setRequestTimeout(120) - ->setCapabilities(new ClientCapabilities(sampling: true)) - ->addNotificationHandler($loggingHandler) - ->addRequestHandler(new SamplingRequestHandler($samplingCallback)) - ->build(); - -// Create transport -$transport = new StdioTransport( - command: 'php', - args: [__DIR__ . '/server.php'], -); - -// Connect and use server -try { - echo "Connecting to server...\n"; - $client->connect($transport); - - // Get server info - $serverInfo = $client->getServerInfo(); - echo "Connected to: {$serverInfo->name} v{$serverInfo->version}\n\n"; - - // List capabilities - echo "Available tools:\n"; - $tools = $client->listTools(); - foreach ($tools->tools as $tool) { - echo " - {$tool->name}\n"; - } - - echo "\nAvailable resources:\n"; - $resources = $client->listResources(); - foreach ($resources->resources as $resource) { - echo " - {$resource->uri}\n"; - } - - // Set logging level - $client->setLoggingLevel(LoggingLevel::Debug); - - // Call tool with progress - echo "\nCalling tool with progress...\n"; - $result = $client->callTool( - name: 'process_data', - arguments: ['dataset' => 'large_file.csv'], - onProgress: static function (float $progress, ?float $total, ?string $message) { - $percent = $total > 0 ? round(($progress / $total) * 100) : 0; - echo " Progress: {$percent}% - {$message}\n"; - } - ); - - echo "\nResult:\n"; - foreach ($result->content as $content) { - if ($content instanceof TextContent) { - echo $content->text . "\n"; - } - } - -} catch (\Throwable $e) { - echo "Error: {$e->getMessage()}\n"; - echo $e->getTraceAsString() . "\n"; -} finally { - $client->disconnect(); - echo "\nDisconnected.\n"; -} -``` diff --git a/docs/client/capabilities.md b/docs/client/capabilities.md new file mode 100644 index 00000000..54aa0df5 --- /dev/null +++ b/docs/client/capabilities.md @@ -0,0 +1,157 @@ +# Tools, resources & prompts + +Once connected, everything the server exposes is reachable through the client: list what +is there, then call it. Each list method returns the server's own descriptions and +schemas, so a generic client can build its UI from them. + +## Working with Tools + +### Listing Tools + +```php +$toolsResult = $client->listTools(); + +foreach ($toolsResult->tools as $tool) { + echo "- {$tool->name}: {$tool->description}\n"; +} + +// Handle pagination +if ($toolsResult->nextCursor) { + $moreTools = $client->listTools($toolsResult->nextCursor); +} +``` + +### Calling Tools + +```php +$result = $client->callTool( + name: 'calculate', + arguments: ['a' => 5, 'b' => 3, 'operation' => 'add'], +); + +// Access results +foreach ($result->content as $content) { + if ($content instanceof TextContent) { + echo $content->text; + } +} +``` + +### Progress Notifications + +Hook into tool execution progress (if server supports it): + +```php +$result = $client->callTool( + name: 'long_running_task', + arguments: ['data' => 'large_dataset'], + onProgress: static function (float $progress, ?float $total, ?string $message) { + $percent = $total > 0 ? round(($progress / $total) * 100) : 0; + echo "Progress: {$percent}% - {$message}\n"; + } +); +``` + +!!! note + Progress notifications are only received if the server sends them. The callback will not be invoked if the server doesn't support or send progress updates. + +## Working with Resources + +### Listing Resources + +```php +$resourcesResult = $client->listResources(); + +foreach ($resourcesResult->resources as $resource) { + echo "- {$resource->uri}: {$resource->name}\n"; +} +``` + +### Listing Resource Templates + +```php +$templatesResult = $client->listResourceTemplates(); + +foreach ($templatesResult->resourceTemplates as $template) { + echo "- {$template->uriTemplate}: {$template->name}\n"; +} +``` + +### Reading Resources + +```php +$resourceResult = $client->readResource('config://app/settings'); + +foreach ($resourceResult->contents as $content) { + if ($content instanceof TextResourceContents) { + echo "Text: {$content->text}\n"; + } elseif ($content instanceof BlobResourceContents) { + echo "Binary data (base64): {$content->blob}\n"; + } +} +``` + +Resources also support progress notifications: + +```php +$result = $client->readResource( + uri: 'file://large-file.bin', + onProgress: static function (float $progress, ?float $total, ?string $message) { + echo "Reading: {$progress}/{$total} bytes\n"; + } +); +``` + +## Working with Prompts + +### Listing Prompts + +```php +$promptsResult = $client->listPrompts(); + +foreach ($promptsResult->prompts as $prompt) { + echo "- {$prompt->name}: {$prompt->description}\n"; +} +``` + +### Getting Prompts + +```php +$promptResult = $client->getPrompt( + name: 'code_review', + arguments: ['language' => 'php', 'code' => '...'], +); + +foreach ($promptResult->messages as $message) { + echo "{$message->role->value}: {$message->content->text}\n"; +} +``` + +Prompts also support progress notifications: + +```php +$result = $client->getPrompt( + name: 'generate_report', + arguments: ['topic' => 'quarterly_analysis'], + onProgress: static function (float $progress, ?float $total, ?string $message) { + echo "Generating: {$message}\n"; + } +); +``` + +### Requesting Completions + +Request auto-completion suggestions for prompt or resource arguments: + +```php +use Mcp\Schema\PromptReference; + +$completionResult = $client->complete( + ref: new PromptReference('code_review'), + argument: ['name' => 'language', 'value' => 'ph'], +); + +foreach ($completionResult->values as $value) { + echo "Suggestion: {$value}\n"; +} +``` diff --git a/docs/client/connecting.md b/docs/client/connecting.md new file mode 100644 index 00000000..22bc011d --- /dev/null +++ b/docs/client/connecting.md @@ -0,0 +1,181 @@ +# Connecting to a server + +A client is configured once through its builder, then connected to a +[transport](transports.md). Connecting performs the MCP initialization handshake, after +which the server's capabilities are known and its elements can be used. + +## Client Builder + +The `Client\Builder` provides fluent configuration of client instances. + +### Basic Configuration + +```php +use Mcp\Client; + +$client = Client::builder() + ->setClientInfo('My Application', '1.0.0', 'Description of my client') + ->setInitTimeout(30) // Seconds to wait for initialization + ->setRequestTimeout(120) // Seconds to wait for request responses + ->build(); +``` + +!!! note + The builder also exposes `setMaxRetries()`, but the value is currently stored and never acted on — no transport + retries a failed connection. Do not rely on it. + +### Client Information + +Set the client's identity reported to servers during initialization: + +```php +$client = Client::builder() + ->setClientInfo( + name: 'AI Assistant Client', + version: '2.1.0', + description: 'Client for automated AI workflows' + ) + ->build(); +``` + +### Protocol Version + +Specify the MCP protocol version (defaults to latest): + +```php +use Mcp\Schema\Enum\ProtocolVersion; + +$client = Client::builder() + ->setProtocolVersion(ProtocolVersion::V2025_11_25) + ->build(); +``` + +### Capabilities + +Declare client capabilities to enable server features: + +```php +use Mcp\Schema\ClientCapabilities; + +$client = Client::builder() + ->setCapabilities(new ClientCapabilities( + sampling: true, // Enable LLM sampling requests from server + roots: true, // Enable filesystem root listing + )) + ->build(); +``` + +### Notification Handlers + +Register handlers for server-initiated notifications: + +```php +use Mcp\Client\Handler\Notification\LoggingNotificationHandler; +use Mcp\Schema\Notification\LoggingMessageNotification; + +$loggingHandler = new LoggingNotificationHandler( + static function (LoggingMessageNotification $notification) { + echo "[{$notification->level->value}] {$notification->data}\n"; + } +); + +$client = Client::builder() + ->addNotificationHandler($loggingHandler) + ->build(); +``` + +### Request Handlers + +Register handlers for server-initiated requests (e.g., sampling): + +```php +use Mcp\Client\Handler\Request\SamplingRequestHandler; +use Mcp\Client\Handler\Request\SamplingCallbackInterface; +use Mcp\Schema\Request\CreateSamplingMessageRequest; +use Mcp\Schema\Result\CreateSamplingMessageResult; + +$samplingCallback = new class implements SamplingCallbackInterface { + public function __invoke(CreateSamplingMessageRequest $request): CreateSamplingMessageResult + { + // Perform LLM sampling and return result + } +}; + +$client = Client::builder() + ->addRequestHandler(new SamplingRequestHandler($samplingCallback)) + ->build(); +``` + +### Logger + +Configure PSR-3 logging for debugging: + +```php +use Monolog\Logger; +use Monolog\Handler\StreamHandler; + +$logger = new Logger('mcp-client'); +$logger->pushHandler(new StreamHandler('client.log', Logger::DEBUG)); + +$client = Client::builder() + ->setLogger($logger) + ->build(); +``` + +## Connecting to Servers + +### Establishing Connection + +```php +$client->connect($transport); +``` + +The `connect()` method performs the MCP initialization handshake: +1. Opens the transport connection +2. Sends InitializeRequest with client capabilities +3. Waits for InitializeResult from server +4. Sends InitializedNotification + +!!! warning + Always wrap connection in try/catch to handle `ConnectionException` for failed connections. + +### Checking Connection State + +```php +if ($client->isConnected()) { + // Client is connected and initialized +} +``` + +### Disconnecting + +```php +$client->disconnect(); +``` + +Always disconnect when finished to clean up resources: + +```php +try { + $client->connect($transport); + // ... use the client ... +} finally { + $client->disconnect(); +} +``` + +## Server Information + +After successful connection, retrieve server metadata: + +```php +// Get server implementation info +$serverInfo = $client->getServerInfo(); +echo "Server: {$serverInfo->name} v{$serverInfo->version}\n"; + +// Get server instructions +$instructions = $client->getInstructions(); +if ($instructions) { + echo "Instructions: {$instructions}\n"; +} +``` diff --git a/docs/client/errors.md b/docs/client/errors.md new file mode 100644 index 00000000..0b3aff0b --- /dev/null +++ b/docs/client/errors.md @@ -0,0 +1,155 @@ +# Error Handling + +The client throws exceptions for various error conditions: + +## ConnectionException + +Thrown when connection or initialization fails: + +```php +use Mcp\Exception\ConnectionException; + +try { + $client->connect($transport); +} catch (ConnectionException $e) { + echo "Failed to connect: {$e->getMessage()}\n"; +} +``` + +## RequestException + +Thrown when a request returns an error response: + +```php +use Mcp\Exception\RequestException; + +try { + $result = $client->callTool('unknown_tool', []); +} catch (RequestException $e) { + echo "Request failed: {$e->getMessage()}\n"; + echo "Error code: {$e->getCode()}\n"; +} +``` + +## Complete Example + +Here's a comprehensive example demonstrating client usage: + +```php-file +level->value}] {$notification->data}\n"; + } +); + +// Configure sampling callback +$samplingCallback = new class implements SamplingCallbackInterface { + public function __invoke(CreateSamplingMessageRequest $request): CreateSamplingMessageResult + { + echo "[SAMPLING] Processing request (max {$request->maxTokens} tokens)\n"; + + try { + // Integration with your LLM provider + $response = "This is a mock LLM response for: " . + json_encode($request->messages); + + return new CreateSamplingMessageResult( + role: Role::Assistant, + content: new TextContent($response), + model: 'mock-llm', + stopReason: 'end_turn', + ); + } catch (\Throwable $e) { + throw new SamplingException( + "Sampling failed: {$e->getMessage()}", + 0, + $e + ); + } + } +}; + +// Build client +$client = Client::builder() + ->setClientInfo('Example Client', '1.0.0') + ->setInitTimeout(30) + ->setRequestTimeout(120) + ->setCapabilities(new ClientCapabilities(sampling: true)) + ->addNotificationHandler($loggingHandler) + ->addRequestHandler(new SamplingRequestHandler($samplingCallback)) + ->build(); + +// Create transport +$transport = new StdioTransport( + command: 'php', + args: [__DIR__ . '/server.php'], +); + +// Connect and use server +try { + echo "Connecting to server...\n"; + $client->connect($transport); + + // Get server info + $serverInfo = $client->getServerInfo(); + echo "Connected to: {$serverInfo->name} v{$serverInfo->version}\n\n"; + + // List capabilities + echo "Available tools:\n"; + $tools = $client->listTools(); + foreach ($tools->tools as $tool) { + echo " - {$tool->name}\n"; + } + + echo "\nAvailable resources:\n"; + $resources = $client->listResources(); + foreach ($resources->resources as $resource) { + echo " - {$resource->uri}\n"; + } + + // Set logging level + $client->setLoggingLevel(LoggingLevel::Debug); + + // Call tool with progress + echo "\nCalling tool with progress...\n"; + $result = $client->callTool( + name: 'process_data', + arguments: ['dataset' => 'large_file.csv'], + onProgress: static function (float $progress, ?float $total, ?string $message) { + $percent = $total > 0 ? round(($progress / $total) * 100) : 0; + echo " Progress: {$percent}% - {$message}\n"; + } + ); + + echo "\nResult:\n"; + foreach ($result->content as $content) { + if ($content instanceof TextContent) { + echo $content->text . "\n"; + } + } + +} catch (\Throwable $e) { + echo "Error: {$e->getMessage()}\n"; + echo $e->getTraceAsString() . "\n"; +} finally { + $client->disconnect(); + echo "\nDisconnected.\n"; +} +``` diff --git a/docs/client/index.md b/docs/client/index.md new file mode 100644 index 00000000..3bd172d1 --- /dev/null +++ b/docs/client/index.md @@ -0,0 +1,39 @@ +# Clients + +The client side is for applications that *use* MCP servers: you connect to a server, +discover what it offers, and call it. The API is synchronous — every method returns a +result or throws. + +```php +use Mcp\Client; +use Mcp\Client\Transport\StdioTransport; + +// Build and configure the client +$client = Client::builder() + ->setClientInfo('My Client', '1.0.0') + ->setInitTimeout(30) + ->setRequestTimeout(120) + ->build(); + +// Create a transport +$transport = new StdioTransport( + command: 'php', + args: ['/path/to/server.php'], +); + +// Connect and use the server +$client->connect($transport); +$tools = $client->listTools(); +$client->disconnect(); +``` + +* **[Connecting to a server](connecting.md)** — the builder, the connection lifecycle, + and what the server told you about itself during initialization. +* **[Transports](transports.md)** — launching a local server process (STDIO) or talking + to a remote one (HTTP). +* **[Tools, resources & prompts](capabilities.md)** — listing and calling everything a + server exposes, including progress callbacks and completions. +* **[Server-initiated requests](server-requests.md)** — the other direction: log + messages, sampling requests, and elicitations the server sends *you*. +* **[Error handling](errors.md)** — which exception means what, plus a complete + end-to-end example. diff --git a/docs/client/server-requests.md b/docs/client/server-requests.md new file mode 100644 index 00000000..39637acf --- /dev/null +++ b/docs/client/server-requests.md @@ -0,0 +1,169 @@ +# Server-Initiated Communication + +The client can receive requests and notifications from the server when configured with appropriate handlers. + +## Logging Notifications + +Receive structured log messages from the server: + +```php +use Mcp\Client\Handler\Notification\LoggingNotificationHandler; +use Mcp\Schema\Notification\LoggingMessageNotification; +use Mcp\Schema\Enum\LoggingLevel; + +$loggingHandler = new LoggingNotificationHandler( + static function (LoggingMessageNotification $notification) { + // Route to your application's logging system + $level = $notification->level; + $message = $notification->data; + + match ($level) { + LoggingLevel::Debug => logger()->debug($message), + LoggingLevel::Info => logger()->info($message), + LoggingLevel::Warning => logger()->warning($message), + LoggingLevel::Error => logger()->error($message), + default => logger()->info($message), + }; + } +); + +$client = Client::builder() + ->addNotificationHandler($loggingHandler) + ->build(); + +// Set minimum log level (optional) +$client->setLoggingLevel(LoggingLevel::Info); +``` + +## Sampling (LLM Requests) + +Handle server requests for LLM completions: + +```php +use Mcp\Client\Handler\Request\SamplingRequestHandler; +use Mcp\Client\Handler\Request\SamplingCallbackInterface; +use Mcp\Exception\SamplingException; +use Mcp\Schema\ClientCapabilities; +use Mcp\Schema\Request\CreateSamplingMessageRequest; +use Mcp\Schema\Result\CreateSamplingMessageResult; +use Mcp\Schema\Content\TextContent; +use Mcp\Schema\Enum\Role; + +class LlmSamplingCallback implements SamplingCallbackInterface +{ + public function __invoke(CreateSamplingMessageRequest $request): CreateSamplingMessageResult + { + try { + // Call your LLM provider + $response = $this->llmClient->complete( + messages: $request->messages, + maxTokens: $request->maxTokens, + temperature: $request->temperature ?? 0.7, + ); + + return new CreateSamplingMessageResult( + role: Role::Assistant, + content: new TextContent($response->text), + model: $response->model, + stopReason: $response->stopReason, + ); + } catch (\Throwable $e) { + // Throw SamplingException to surface error to server + throw new SamplingException( + "LLM sampling failed: {$e->getMessage()}", + (int) $e->getCode(), + $e + ); + } + } +} + +$client = Client::builder() + ->setCapabilities(new ClientCapabilities(sampling: true)) + ->addRequestHandler(new SamplingRequestHandler(new LlmSamplingCallback)) + ->build(); +``` + +!!! warning + **Error Handling in Sampling Callbacks:** + + When implementing sampling callbacks, error handling is critical: + + - **Throw `SamplingException`** to forward specific error messages to the server + - **Any other exception** will be logged but return a generic error to the server + + This distinction allows you to control what error information the server receives: + + ```php + // Good: Server receives "Rate limit exceeded" message + throw new SamplingException('Rate limit exceeded. Retry after 60 seconds.'); + + // Bad: Server receives generic "Error while sampling LLM" message + throw new \RuntimeException('Rate limit exceeded'); + ``` + +## Elicitation (User Input Requests) + +Handle server requests to elicit additional information from the user during tool +execution. The server sends an `elicitation/create` request describing the fields it +needs; your callback presents them to the user and returns an `ElicitResult` with one of +three actions — accept (with the collected content), decline, or cancel: + +```php +use Mcp\Client\Handler\Request\ElicitationRequestHandler; +use Mcp\Client\Handler\Request\ElicitationCallbackInterface; +use Mcp\Exception\ElicitationException; +use Mcp\Schema\ClientCapabilities; +use Mcp\Schema\Enum\ElicitAction; +use Mcp\Schema\Request\ElicitRequest; +use Mcp\Schema\Result\ElicitResult; + +class ConsoleElicitationCallback implements ElicitationCallbackInterface +{ + public function __invoke(ElicitRequest $request): ElicitResult + { + echo $request->message.\PHP_EOL; + + // Present $request->requestedSchema->properties to the user and collect input. + $content = []; + foreach ($request->requestedSchema->properties as $name => $definition) { + $answer = readline($definition->title.': '); + + if (false === $answer) { + // No input available — let the server know the user cancelled. + return new ElicitResult(ElicitAction::Cancel); + } + + $content[$name] = $answer; + } + + return new ElicitResult(ElicitAction::Accept, $content); + } +} + +$client = Client::builder() + ->setCapabilities(new ClientCapabilities(elicitation: true)) + ->addRequestHandler(new ElicitationRequestHandler(new ConsoleElicitationCallback)) + ->build(); +``` + +Return `new ElicitResult(ElicitAction::Decline)` when the user refuses to provide the +information, and `new ElicitResult(ElicitAction::Cancel)` when they dismiss the request. +Only the `Accept` action carries content. + +!!! warning + **Error Handling in Elicitation Callbacks:** + + - **Throw `ElicitationException`** to forward a specific error message to the server + - **Any other exception** is logged but returns a generic error to the server + + ```php + // Good: Server receives "No interactive console available" message + throw new ElicitationException('No interactive console available'); + + // Bad: Server receives generic "Error while processing elicitation" message + throw new \RuntimeException('No interactive console available'); + ``` + +See `examples/client/stdio_elicitation.php` for a runnable example against the +elicitation demo server. diff --git a/docs/client/transports.md b/docs/client/transports.md new file mode 100644 index 00000000..edfc131e --- /dev/null +++ b/docs/client/transports.md @@ -0,0 +1,59 @@ +# Transports + +Transports handle the communication layer between client and server. + +## STDIO Transport + +Spawns a server process and communicates via standard input/output: + +```php +use Mcp\Client\Transport\StdioTransport; + +$transport = new StdioTransport( + command: 'php', + args: ['/path/to/server.php'], + cwd: '/working/directory', // Optional working directory + env: ['KEY' => 'value'], // Optional environment variables +); +``` + +**Parameters:** +- `command` (string): The command to execute +- `args` (array): Command arguments +- `cwd` (string|null): Working directory for the process +- `env` (array|null): Environment variables +- `logger` (LoggerInterface|null): Optional PSR-3 logger + +## HTTP Transport + +Communicates with remote MCP servers over HTTP: + +```php +use Mcp\Client\Transport\HttpTransport; + +$transport = new HttpTransport( + endpoint: 'http://localhost:8000', + headers: ['Authorization' => 'Bearer token'], +); +``` + +**Parameters:** +- `endpoint` (string): The MCP server URL +- `headers` (array): Additional HTTP headers +- `httpClient` (ClientInterface|null): PSR-18 HTTP client (auto-discovered) +- `requestFactory` (RequestFactoryInterface|null): PSR-17 request factory (auto-discovered) +- `streamFactory` (StreamFactoryInterface|null): PSR-17 stream factory (auto-discovered) +- `logger` (LoggerInterface|null): Optional PSR-3 logger + +**PSR-18 Auto-Discovery:** + +The transport automatically discovers PSR-18 HTTP clients from: +- `php-http/guzzle7-adapter` +- `php-http/curl-client` +- `symfony/http-client` +- And other PSR-18 compatible implementations + +```bash +# Install any PSR-18 client - discovery works automatically +composer require php-http/guzzle7-adapter +``` diff --git a/docs/examples.md b/docs/examples.md index 14e97fde..f630d922 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -3,13 +3,6 @@ The MCP PHP SDK includes comprehensive examples demonstrating different patterns and use cases. Each example showcases specific features and can be run independently to understand how the SDK works. -## Table of Contents - -- [Getting Started](#getting-started) -- [Running Examples](#running-examples) -- [Server Examples](#server-examples) -- [Client Examples](#client-examples) - ## Getting Started All examples are located in the `examples/` directory and use the SDK dependencies from the root project. Most examples @@ -32,13 +25,13 @@ The STDIO transport will use standard input/output for communication: ```bash # Interactive testing with MCP Inspector -npx @modelcontextprotocol/inspector php examples/discovery-calculator/server.php +npx @modelcontextprotocol/inspector php examples/server/discovery-calculator/server.php # Run with debugging enabled -npx @modelcontextprotocol/inspector -e DEBUG=1 -e FILE_LOG=1 php examples/discovery-calculator/server.php +npx @modelcontextprotocol/inspector -e DEBUG=1 -e FILE_LOG=1 php examples/server/discovery-calculator/server.php # Or configure the script path in your MCP client -# Path: php examples/discovery-calculator/server.php +# Path: php examples/server/discovery-calculator/server.php ``` ### HTTP Transport @@ -47,7 +40,7 @@ The Streamable HTTP transport will be chosen if running examples with a web serv ```bash # Start the server -php -S localhost:8000 examples/discovery-userprofile/server.php +php -S localhost:8000 examples/server/discovery-userprofile/server.php # Test with MCP Inspector npx @modelcontextprotocol/inspector http://localhost:8000 @@ -63,7 +56,7 @@ curl -X POST http://localhost:8000 \ ### Discovery Calculator -**File**: `examples/discovery-calculator/` +**File**: `examples/server/discovery-calculator/` **What it demonstrates:** - Attribute-based discovery using `#[McpTool]` and `#[McpResource]` @@ -87,14 +80,14 @@ public function getConfiguration(): array **Usage:** ```bash # Interactive testing -npx @modelcontextprotocol/inspector php examples/discovery-calculator/server.php +npx @modelcontextprotocol/inspector php examples/server/discovery-calculator/server.php -# Or configure in MCP client: php examples/discovery-calculator/server.php +# Or configure in MCP client: php examples/server/discovery-calculator/server.php ``` ### Explicit Registration -**File**: `examples/explicit-registration/` +**File**: `examples/server/explicit-registration/` **What it demonstrates:** - Manual registration of tools, resources, and prompts @@ -111,7 +104,7 @@ $server = Server::builder() ### Environment Variables -**File**: `examples/env-variables/` +**File**: `examples/server/env-variables/` **What it demonstrates:** - Environment variable integration @@ -125,7 +118,7 @@ $server = Server::builder() ### Custom Dependencies -**File**: `examples/custom-dependencies/` +**File**: `examples/server/custom-dependencies/` **What it demonstrates:** - Dependency injection with PSR-11 containers @@ -145,7 +138,7 @@ $server = Server::builder() ### Cached Discovery -**File**: `examples/cached-discovery/` +**File**: `examples/server/cached-discovery/` **What it demonstrates:** - Discovery caching for improved performance @@ -165,7 +158,7 @@ $server = Server::builder() ### Client Communication -**File**: `examples/client-communication/` +**File**: `examples/server/client-communication/` **What it demonstrates:** - Server initiated communication back to the client @@ -174,7 +167,7 @@ $server = Server::builder() ### Discovery User Profile -**File**: `examples/discovery-userprofile/` +**File**: `examples/server/discovery-userprofile/` **What it demonstrates:** - HTTP transport with StreamableHttpTransport @@ -202,7 +195,7 @@ public function generateBio(string $userId, string $tone = 'professional'): arra **Usage:** ```bash # Start the HTTP server -php -S localhost:8000 examples/discovery-userprofile/server.php +php -S localhost:8000 examples/server/discovery-userprofile/server.php # Test with MCP Inspector npx @modelcontextprotocol/inspector http://localhost:8000 @@ -212,7 +205,7 @@ npx @modelcontextprotocol/inspector http://localhost:8000 ### Combined Registration -**File**: `examples/combined-registration/` +**File**: `examples/server/combined-registration/` **What it demonstrates:** - Mixing attribute discovery with manual registration @@ -235,7 +228,7 @@ $server = Server::builder() ### Complex Tool Schema -**File**: `examples/complex-tool-schema/` +**File**: `examples/server/complex-tool-schema/` **What it demonstrates:** - Advanced JSON schema definitions @@ -258,7 +251,7 @@ public function scheduleEvent(array $eventData): array ### Schema Showcase -**File**: `examples/schema-showcase/` +**File**: `examples/server/schema-showcase/` **What it demonstrates:** - Comprehensive JSON schema features @@ -365,7 +358,7 @@ npx @modelcontextprotocol/inspector php examples/server/elicitation/server.php **File**: `examples/server/mcp-apps/` -A weather app demonstrating the [MCP Apps extension](extensions.md): a `ui://` +A weather app demonstrating the [MCP Apps extension](advanced/extensions.md): a `ui://` HTML resource is opened by an MCP App-aware client (e.g. Goose) and bridged to the `get_weather` tool. The bundled `weather-app.html` performs the `ui/initialize` handshake, reports its size via `ui/notifications/size-changed`, @@ -431,8 +424,9 @@ $prompts = $client->listPrompts(); **Usage:** ```bash -# Start the server first -php -S localhost:8000 examples/server/http-discovery-calculator/server.php +# Start the server first — the example picks its transport from the SAPI, +# so running it under a web server makes it speak Streamable HTTP +php -S localhost:8000 examples/server/discovery-calculator/server.php # Then run the client php examples/client/http_discovery_calculator.php @@ -516,5 +510,5 @@ php -S 127.0.0.1:8000 examples/server/client-communication/server.php php examples/client/http_client_communication.php ``` -> [!NOTE] -> For sampling with HTTP transport, the server must support concurrent request processing (e.g., using Symfony CLI, PHP-FPM, or a production web server). PHP's built-in development server cannot handle the concurrent requests required for sampling. +!!! note + For sampling with HTTP transport, the server must support concurrent request processing (e.g., using Symfony CLI, PHP-FPM, or a production web server). PHP's built-in development server cannot handle the concurrent requests required for sampling. diff --git a/docs/get-started/first-server.md b/docs/get-started/first-server.md new file mode 100644 index 00000000..1fbb8a31 --- /dev/null +++ b/docs/get-started/first-server.md @@ -0,0 +1,84 @@ +# First server + +A server is a plain PHP class plus three lines of wiring. Create `server.php` next to +your `vendor/` directory: + +```php-file title="server.php" +#!/usr/bin/env php + 2]; + } +} + +exit(Server::builder() + ->setServerInfo('Calculator', '1.0.0') + ->setDiscovery(__DIR__, ['.'], excludeDirs: ['vendor']) + ->build() + ->run(new StdioTransport())); +``` + +Discovery needs `symfony/finder`: + +```bash +composer require symfony/finder +``` + +## What each piece does + +`#[McpTool]` marks a method as an action the *model* can call. Its name defaults to the +method name, its description comes from the docblock (the summary, plus the longer +description if you write one), and its input schema is +generated from the parameter types — `int $a, int $b` becomes a JSON Schema with two +required integers. See [Tools](../servers/tools.md). + +`#[McpResource]` marks a method as read-only data the *application* can read, addressed +by URI. See [Resources](../servers/resources.md). + +`setDiscovery(__DIR__, ['.'], excludeDirs: ['vendor'])` scans those directories for +attributed classes. Scanning is lazy: it happens on the first request that needs the +registry, not when `build()` returns — call `setLazyLoading(false)` if you would rather +pay for it up front. Excluding `vendor` matters because the scan is recursive and would +otherwise read and autoload every file your dependencies ship. If you would rather +register elements explicitly — or mix both — see +[Registering elements](../servers/registration.md). + +`run(new StdioTransport())` speaks JSON-RPC over stdin/stdout and returns an exit code. +That is the transport local MCP hosts launch as a subprocess; for a web-facing server +use the [HTTP transport](../run/http.md) instead. + +!!! warning "Never write to STDOUT" + With the STDIO transport, `STDOUT` carries the protocol. `echo`, `print_r()`, or a + stray `var_dump()` in a handler corrupts the stream. Write to `STDERR`, or use the + [logger](../handlers/logging.md). + +## Run it + +```bash +php server.php +``` + +Nothing happens — the server is waiting for JSON-RPC on stdin, which is exactly right. +Stop it with `Ctrl+C`, and let a real client drive it instead: +[Try it with the Inspector](inspector.md). diff --git a/docs/get-started/index.md b/docs/get-started/index.md new file mode 100644 index 00000000..eada9d31 --- /dev/null +++ b/docs/get-started/index.md @@ -0,0 +1,27 @@ +# Get started + +New to MCP, or new to this SDK? Start here. These pages take you from nothing to a +server a real MCP host can talk to: [install the SDK](installation.md), build your +[first server](first-server.md), and [open it in the Inspector](inspector.md). + +## Run the code + +Every code block on these pages is a complete, working file — copy it into +`server.php` next to your `vendor/` directory and run it. + +It is worth actually typing (or pasting) and running them: what the SDK does for you +only really shows up in your own editor, where the type hints you write turn into the +schema a model sees. + +## Where to go next + +Once you have a server running, the rest of these docs are a reference, not a course. +Every page stands on its own, so jump straight to what you need: + +* What a server exposes (tools, resources, prompts) is **[Servers](../servers/index.md)**. +* Getting it in front of clients (STDIO, HTTP, an existing framework app) is + **[Running your server](../run/index.md)**. +* What is available inside the functions you register is + **[Inside your handler](../handlers/index.md)**. +* Building the other side, an application that *uses* MCP servers, is + **[Clients](../client/index.md)**. diff --git a/docs/get-started/inspector.md b/docs/get-started/inspector.md new file mode 100644 index 00000000..86650e8f --- /dev/null +++ b/docs/get-started/inspector.md @@ -0,0 +1,88 @@ +# Try it with the Inspector + +The [MCP Inspector](https://github.com/modelcontextprotocol/inspector) is an interactive +UI for poking at a server: it lists what the server exposes and lets you call it by +hand. It is the fastest way to see whether your server does what you think it does. + +It is a Node.js application, so this needs `npx` on your `PATH`. + +## STDIO + +Point the Inspector at the command that starts your server — it launches the process +itself: + +```bash +npx @modelcontextprotocol/inspector php server.php +``` + +Open the URL it prints. Under **Tools**, call `add` with `a=1` and `b=2`; you get `3` +back. The form the Inspector built for you — a required integer field for each argument +— came from the type hints on the method. So will the schema every other MCP host sees. + +Under **Resources**, read `config://calculator/settings` to get the array back as JSON. + +## HTTP + +A server behind a web server speaks the [HTTP transport](../run/http.md) instead, so the +last lines of `server.php` change — `StdioTransport` reads stdin and would just block +under `php -S`: + +```php title="server.php (HTTP variant)" +use Http\Discovery\Psr17Factory; +use Mcp\Server\Session\FileSessionStore; +use Mcp\Server\Transport\StreamableHttpTransport; +use Laminas\HttpHandlerRunner\Emitter\SapiEmitter; + +$request = (new Psr17Factory())->createServerRequestFromGlobals(); + +$response = Server::builder() + ->setServerInfo('Calculator', '1.0.0') + ->setDiscovery(__DIR__, ['.'], excludeDirs: ['vendor']) + ->setSession(new FileSessionStore(__DIR__.'/sessions')) + ->build() + ->run(new StreamableHttpTransport($request)); + +(new SapiEmitter())->emit($response); +``` + +That needs a PSR-17 implementation and an emitter +(`composer require nyholm/psr7 laminas/laminas-httphandlerrunner`). Start it, then give +the Inspector its URL: + +```bash +php -S localhost:8000 server.php +npx @modelcontextprotocol/inspector http://localhost:8000 +``` + +`curl` works too, if you would rather see the wire format: + +```bash +curl -X POST http://localhost:8000 \ + -H "Content-Type: application/json" \ + -H "Accept: application/json, text/event-stream" \ + -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","clientInfo":{"name":"test","version":"1.0.0"},"capabilities":{}}}' +``` + +## Connect a real host + +Hosts that launch local servers take the same command the Inspector did. For Claude +Desktop, that is an entry in its configuration file: + +```json +{ + "mcpServers": { + "calculator": { + "command": "php", + "args": ["/absolute/path/to/server.php"] + } + } +} +``` + +Use an absolute path: the host does not run the command from your project directory. + +## Next + +* Add more of what a server can expose: **[Servers](../servers/index.md)**. +* Put it on the web instead of stdin/stdout: **[HTTP transport](../run/http.md)**. +* Drive a server from PHP instead of a UI: **[Clients](../client/index.md)**. diff --git a/docs/get-started/installation.md b/docs/get-started/installation.md new file mode 100644 index 00000000..4edca464 --- /dev/null +++ b/docs/get-started/installation.md @@ -0,0 +1,44 @@ +# Installation + +The SDK ships as a single Composer package: + +```bash +composer require mcp/sdk +``` + +It requires **PHP 8.1+** and the `fileinfo` extension. Most of what it pulls in is PSR +interface packages (`psr/container`, `psr/log`, `psr/http-message`, …); the rest is +`opis/json-schema` for schema validation, `symfony/uid` for session identifiers, +`phpdocumentor/reflection-docblock` for reading descriptions out of your docblocks, and +`php-http/discovery` for finding PSR-17/PSR-18 implementations. + +That is enough for a complete [STDIO server](../run/stdio.md) and for the +[STDIO client](../client/transports.md). + +## Optional packages + +Which extras you need depends on what you build: + +| You want to… | Also install | +| --- | --- | +| discover `#[McpTool]` & friends from a directory | `symfony/finder` | +| serve over [HTTP](../run/http.md) | any PSR-17 implementation, e.g. `nyholm/psr7` | +| emit the response from a standalone HTTP entry point | `laminas/laminas-httphandlerrunner` | +| [connect a client over HTTP](../client/transports.md) | any PSR-18 client, e.g. `symfony/http-client` | +| [validate JWT access tokens](../run/authorization.md) | `firebase/php-jwt` | +| store sessions in a PSR-16 cache | `psr/simple-cache` implementation, e.g. `symfony/cache` | + +PSR-17 and PSR-18 implementations are found through +[`php-http/discovery`](https://docs.php-http.org/en/latest/discovery.html), so +installing the package is all that is needed — no wiring: + +```bash +composer require nyholm/psr7 +``` + +If discovery picks the wrong one, or you want to be explicit, pass the factories to the +transport yourself; see [HTTP transport](../run/http.md). + +## Next + +Write your [first server](first-server.md). diff --git a/docs/server-client-communication.md b/docs/handlers/client-communication.md similarity index 71% rename from docs/server-client-communication.md rename to docs/handlers/client-communication.md index dbb5c9fd..fc803221 100644 --- a/docs/server-client-communication.md +++ b/docs/handlers/client-communication.md @@ -1,26 +1,14 @@ -# Client Communication +# Talking back to the client -MCP supports various ways a server can communicate back to a client on top of the main request-response flow. - -> **Protocol revision `2026-07-28`.** This page describes the handshake era, where a server sends its own -> JSON-RPC requests to the client. The modern lifecycle removed that: sampling, elicitation and roots are -> carried back inside the *result* instead, and `ClientGateway::sample()`, `elicit()` and `listRoots()` -> raise a `LogicException` there. Logging and progress still work as described below — they simply travel -> on the request's own response stream, and the client opts into each. See -> [The 2026-07-28 Lifecycle](stateless-lifecycle.md). - -## Table of Contents - -- [ClientGateway](#clientgateway) -- [Sampling](#sampling) -- [Logging](#logging) -- [Notification](#notification) -- [Progress](#progress) +MCP supports various ways a server can communicate back to a client on top of the main +request-response flow. ## ClientGateway Every communication back to client is handled using the `Mcp\Server\ClientGateway` and its dedicated methods per -operation. To use the `ClientGateway` in your code, you need to use method argument injection for `RequestContext`. +operation. Reach it through method argument injection for `RequestContext`. (A `ClientGateway`-typed parameter is +injected too, but unlike `RequestContext` it is not excluded from the generated input schema, so it would show up as +an argument of your tool.) Every reference of a MCP element, that translates to an actual method call, can just add an type-hinted argument for the `RequestContext` and the SDK will take care to include the gateway in the arguments of the method call: @@ -50,8 +38,6 @@ if ($context->getProtocolVersion()->isAtLeast(ProtocolVersion::V2026_07_28)) { ## Sampling -> **Deprecated** since protocol revision `2026-07-28` ([SEP-2577](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577)), earliest removal `2027-07-28`. Sampling keeps working until then; new integrations should call an LLM provider's API directly instead. - With [sampling](https://modelcontextprotocol.io/specification/2025-11-25/client/sampling) servers can request clients to execute "completions" or "generations" with a language model for them: @@ -61,7 +47,7 @@ $result = $clientGateway->sample('Roses are red, violets are', 350, 90, ['temper The `sample` method accepts four arguments: -1. `message`, which is **required** and accepts a string, an instance of `Content` or an array of `SamplingMessage` instances. +1. `message`, which is **required** and accepts a string, an instance of `Content` or an array of `Mcp\Schema\Content\SamplingMessage` instances. 2. `maxTokens`, which defaults to `1000` 3. `timeout` in seconds, which defaults to `120` 4. `options` which might include `systemPrompt`, `preferences` for model choice, `includeContext`, `temperature`, @@ -99,8 +85,6 @@ Use `$result->getContentBlocks()` to iterate the response regardless of whether ## Logging -> **Deprecated** since protocol revision `2026-07-28` ([SEP-2577](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577)), earliest removal `2027-07-28`. Logging keeps working until then; new integrations should log to stderr (stdio) or use OpenTelemetry instead. - The [Logging](https://modelcontextprotocol.io/specification/2025-06-18/server/utilities/logging) utility enables servers to send structured log messages as notification to clients: @@ -121,7 +105,7 @@ $clientGateway->progress(4.2, 10, 'Downloading needed images.'); ## Notification -Lastly, the server can push all kind of notifications, that implement the `Mcp\Schema\JsonRpc\Notification` interface +Lastly, the server can push all kind of notifications, that extend the abstract `Mcp\Schema\JsonRpc\Notification` class to the client to: ```php diff --git a/docs/handlers/index.md b/docs/handlers/index.md new file mode 100644 index 00000000..a7ae135c --- /dev/null +++ b/docs/handlers/index.md @@ -0,0 +1,32 @@ +# Inside your handler + +The methods you register are ordinary PHP methods, but they are not cut off from the +protocol. Type-hint a `Mcp\Server\RequestContext` argument anywhere in the signature and +the SDK passes it in — that object is the way back to the client mid-request. + +```php +use Mcp\Capability\Attribute\McpTool; +use Mcp\Schema\Content\TextContent; +use Mcp\Server\RequestContext; + +#[McpTool] +public function summarize(string $text, RequestContext $context): string +{ + $context->getClientLogger()->info(\sprintf('Summarizing %d characters', \strlen($text))); + + $result = $context->getClientGateway()->sample("Summarize:\n\n".$text, 500); + + // `content` is TextContent|ImageContent|AudioContent + return $result->content instanceof TextContent ? $result->content->text : ''; +} +``` + +* **[Talking back to the client](client-communication.md)** — the `ClientGateway`: + asking the client's model for a completion (sampling), reporting progress on a long + call, and sending notifications. +* **[Logging](logging.md)** — structured PSR-3 log messages that surface in the client, + not in your server's log file. + +Handlers that need application services (a database connection, an API client) get them +from the container instead; see +[Service dependencies](../run/server-builder.md#service-dependencies). diff --git a/docs/handlers/logging.md b/docs/handlers/logging.md new file mode 100644 index 00000000..f6db7fa5 --- /dev/null +++ b/docs/handlers/logging.md @@ -0,0 +1,31 @@ +# Logging + +The SDK provides support to send log messages to clients. All standard PSR-3 log levels are supported. +Level **warning** is the default level, so anything below it is dropped until the client raises the level with +`logging/setLevel`. + +!!! note + Only the message is forwarded to the client. A PSR-3 `$context` array is accepted for interface compatibility + but is **not** sent — interpolate anything you need into the message itself. + +## Usage + +The SDK automatically injects a `RequestContext` instance into handlers. This can be used to create a `ClientLogger`. + +```php +use Mcp\Capability\Logger\ClientLogger; +use Mcp\Server\RequestContext; + +#[McpTool] +public function processData(string $input, RequestContext $context): array { + $logger = $context->getClientLogger(); + + $logger->info(\sprintf('Processing started for "%s"', $input)); + $logger->warning('Deprecated API used'); + + // ... processing logic ... + + $logger->info('Processing completed'); + return ['result' => 'processed']; +} +``` diff --git a/docs/index.md b/docs/index.md index a1f6f663..ef5ed500 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,12 +1,100 @@ -# MCP PHP SDK Guides - -- [MCP Elements](mcp-elements.md) — Core capabilities (Tools, Resources, Resource Templates, and Prompts) with registration methods. -- [Server Builder](server-builder.md) — Fluent builder class for creating and configuring MCP server instances. -- [Client](client.md) — Client SDK for connecting to and communicating with MCP servers. -- [The 2026-07-28 Lifecycle](stateless-lifecycle.md) — The stateless protocol revision: per-request metadata, `server/discover`, multi round-trip requests, caching and subscriptions. -- [Transports](transports.md) — STDIO and HTTP transport implementations with guidance on choosing between them. -- [Server-Client Communication](server-client-communication.md) — Methods for servers to communicate back to clients: sampling, logging, progress, and notifications. -- [Protocol Extensions](extensions.md) — Opt-in protocol extensions announced during capability negotiation, including MCP Apps (HTML UI resources). -- [Authorization](authorization.md) — OAuth and authorization setup for the HTTP transport. -- [Events](events.md) — Hooking into the server lifecycle with PSR-14 events. -- [Examples](examples.md) — Example projects demonstrating attribute-based discovery, dependency injection, HTTP transport, and more. +# MCP PHP SDK + +The **Model Context Protocol (MCP)** lets applications provide context to LLMs in a +standardized way, separating the concern of *providing* context from the LLM +interaction itself. + +This is the official PHP SDK for it, a collaboration between +[the PHP Foundation](https://thephp.foundation/) and the +[Symfony project](https://symfony.com/). With it you can: + +* **Build MCP servers** that expose tools, resources, and prompts to any MCP host. +* **Build MCP clients** that connect to any MCP server. +* Speak both standard transports: STDIO and Streamable HTTP. + +!!! warning "Experimental until 1.0" + This SDK is [experimental](https://symfony.com/doc/current/contributing/code/experimental.html) + until the first major release; see the + [roadmap](https://github.com/modelcontextprotocol/php-sdk/blob/main/ROADMAP.md) + for what is planned next. + +## Requirements + +PHP 8.1+. + +## Installation + +```bash +composer require mcp/sdk +``` + +See [Installation](get-started/installation.md) for the optional PSR packages an HTTP +server or client needs. + +## Example + +Create a file `server.php`: + +```php-file title="server.php" + 2]; + } +} + +Server::builder() + ->setServerInfo('Calculator', '1.0.0') + ->setDiscovery(__DIR__, ['.'], excludeDirs: ['vendor']) + ->build() + ->run(new StdioTransport()); +``` + +That's a complete MCP server. It exposes one **tool**, `add`, and one **resource**, +`config://calculator/settings`. + +Attribute discovery needs `symfony/finder` (`composer require symfony/finder`). Without +it the server still starts, but discovers nothing and only logs a warning. + +Look at what you did *not* write: no JSON Schema — `int $a, int $b` *is* the schema — +no request parsing, no serialization, no protocol handling. You wrote a PHP class with +type hints and a docblock; the SDK does the rest. + +[First server](get-started/first-server.md) walks through running it, and +[Try it with the Inspector](get-started/inspector.md) opens it in a UI you can click +around in. + +## Where to go next + +* **[Get started](get-started/index.md)** takes you from `composer require` to a server + a real MCP host can talk to. +* What a server exposes — tools, resources, prompts — is **[Servers](servers/index.md)**. +* Getting it in front of clients (STDIO, HTTP, your existing Symfony or Laravel app) is + **[Running your server](run/index.md)**. +* What is available *inside* the functions you register is + **[Inside your handler](handlers/index.md)**. +* Building the other side, an application that *uses* MCP servers, is + **[Clients](client/index.md)**. +* Complete, runnable projects are in **[Examples](examples.md)**. +* Hunting for an exact signature? The **[API Reference](https://php.sdk.modelcontextprotocol.io/api/)** + is generated from the source. diff --git a/docs/mcp-elements.md b/docs/mcp-elements.md deleted file mode 100644 index 34809062..00000000 --- a/docs/mcp-elements.md +++ /dev/null @@ -1,892 +0,0 @@ -# MCP Elements - -MCP elements are the core capabilities of your server: Tools, Resources, Resource Templates, and Prompts. These elements -define what your server can do and how clients can interact with it. The PHP MCP SDK provides both attribute-based -discovery and manual registration methods. - -## Table of Contents - -- [Overview](#overview) -- [Tools](#tools) -- [Resources](#resources) -- [Resource Templates](#resource-templates) -- [Prompts](#prompts) -- [Logging](#logging) -- [Completion Providers](#completion-providers) -- [Schema Generation and Validation](#schema-generation-and-validation) -- [Discovery vs Manual Registration](#discovery-vs-manual-registration) - -## Overview - -MCP defines four types of capabilities: - -- **Tools**: Functions that can be called by clients to perform actions -- **Resources**: Data sources that clients can read (static URIs) -- **Resource Templates**: URI templates for dynamic resources with variables -- **Prompts**: Template generators for AI prompts - -### Registration Methods - -Each capability can be registered using two methods: - -1. **Attribute-Based Discovery**: Use PHP attributes (`#[McpTool]`, `#[McpResource]`, etc.) on methods or classes. The - server automatically discovers and registers them. - -2. **Manual Registration**: Explicitly register capabilities using `ServerBuilder` methods (`addTool()`, `addResource()`, etc.). - -**Priority**: Manual registrations **always override** discovered elements with the same identifier: -- **Tools**: Same `name` -- **Resources**: Same `uri` -- **Resource Templates**: Same `uriTemplate` -- **Prompts**: Same `name` - -For manual registration details, see [Server Builder Manual Registration](server-builder.md#manual-capability-registration). - -For runtime, config-driven elements whose shape is not known at compile time, see -[Explicit element registration](server-builder.md#explicit-element-registration) in the Server Builder docs. - -## Tools - -Tools are callable functions that perform actions and return results. - -```php -use Mcp\Capability\Attribute\McpTool; - -class Calculator -{ - /** - * Performs arithmetic operations with validation. - */ - #[McpTool(name: 'calculate')] - public function performCalculation(float $a, float $b, string $operation): float - { - return match($operation) { - 'add' => $a + $b, - 'subtract' => $a - $b, - 'multiply' => $a * $b, - 'divide' => $b != 0 ? $a / $b : throw new \InvalidArgumentException('Division by zero'), - default => throw new \InvalidArgumentException('Invalid operation') - }; - } -} -``` - -### Parameters - -- **`name`** (optional): Tool identifier. Defaults to method name if not provided. -- **`title`** (optional): Human-readable display title shown in client UI. Distinct from `name`. -- **`description`** (optional): Tool description. Defaults to docblock summary if not provided, otherwise uses method name. -- **`annotations`** (optional): `ToolAnnotations` object for additional metadata. -- **`icons`** (optional): Array of `Icon` objects for visual representation. -- **`meta`** (optional): Arbitrary key-value pairs for custom metadata. - -**Priority for name/description**: Attribute parameters → DocBlock content → Method name - -For tool parameter validation and JSON schema generation, see [Schema Generation and Validation](#schema-generation-and-validation). - -### Tool Return Values - -Tools can return any data type and the SDK will automatically wrap them in appropriate MCP content types. - -#### Automatic Content Wrapping - -```php -// Primitive types → TextContent -public function getString(): string { return "Hello"; } // TextContent -public function getNumber(): int { return 42; } // TextContent -public function getBool(): bool { return true; } // TextContent -public function getArray(): array { return ['key' => 'value']; } // TextContent (JSON) - -// Special cases -public function getNull(): ?string { return null; } // TextContent("(null)") -public function returnVoid(): void { /* no return */ } // Empty content -``` - -#### Explicit Content Types - -For fine control over output formatting: - -```php -use Mcp\Schema\Content\{TextContent, ImageContent, AudioContent, ResourceLink, EmbeddedResource}; - -public function getFormattedCode(): TextContent -{ - return TextContent::code(' 'file://data.json', 'text' => 'File content'] - ); -} - -public function getResourceLink(): ResourceLink -{ - // Reference a resource by URI without embedding its contents, e.g. when - // a tool result would otherwise need to inline many or large resources. - return new ResourceLink( - uri: 'file://data.json', - name: 'data.json', - mimeType: 'application/json' - ); -} -``` - -#### Multiple Content Items - -Return an array of content items: - -```php -public function getMultipleContent(): array -{ - return [ - new TextContent('Here is the analysis:'), - TextContent::code($code, 'php'), - new TextContent('And here is the summary.') - ]; -} -``` - -#### Structured Output - -Besides the human-readable `content`, a tool result can carry a machine-readable `structuredContent` value. Declare its -shape with `outputSchema`, a JSON Schema of type `object`: - -```php -#[McpTool( - name: 'get_weather', - outputSchema: [ - 'type' => 'object', - 'properties' => [ - 'temperature' => ['type' => 'number'], - 'conditions' => ['type' => 'string'], - ], - 'required' => ['temperature', 'conditions'], - ] -)] -public function getWeather(string $city): array -{ - // Sent as `structuredContent`, and JSON-encoded into `content` for clients that ignore it - return ['temperature' => 22.5, 'conditions' => 'sunny']; -} -``` - -The same schema can be passed to manual registration: - -```php -$builder->addTool([WeatherHandler::class, 'getWeather'], outputSchema: [/* ... */]); -``` - -The SDK fills `structuredContent` whenever the return value qualifies — `outputSchema` is what tells clients to expect it -and lets them validate it. What qualifies depends on the protocol revision the call is served under: - -| Return value | `structuredContent` | -|---|---| -| Associative array (`['temperature' => 22.5]`) | The array | -| Object (`stdClass`, DTO, `JsonSerializable`) that serializes to a JSON object | Its JSON representation | -| List (`[1, 2, 3]`, `[['id' => 1], ['id' => 2]]`), or an object serializing to one | Omitted before `2026-07-28`, kept from it on | -| Array holding `Content` instances | Omitted (already carried in `content`) | -| Scalars, `null`, `Content` instances | Omitted | - -Up to revision `2025-11-25`, `structuredContent` had to be a JSON object, so a PHP list — which serializes to a JSON -array — was not emittable and strict clients rejected the whole tool call over one. [SEP-2106][sep-2106], part of -revision `2026-07-28`, widened `outputSchema` to any JSON Schema 2020-12 and `structuredContent` to any JSON value -conforming to it. The SDK picks the rule from the revision negotiated for the call, so a tool serving both eras needs the -object shape to produce structured output everywhere. Wrap the list in a key for that: - -```php -// Structured content only from 2026-07-28 on: a bare list is not a JSON object -public function listUsersFlat(): array -{ - return [['id' => 1], ['id' => 2]]; -} - -#[McpTool(outputSchema: [ - 'type' => 'object', - 'properties' => [ - 'items' => ['type' => 'array', 'items' => ['type' => 'object']], - ], - 'required' => ['items'] -])] -public function listUsers(): array -{ - return ['items' => [['id' => 1], ['id' => 2]]]; -} -``` - -Either way the data reaches the client: a return value with no structured representation is still JSON-encoded into -`content` as a `TextContent`. When a tool declares an `outputSchema` but returns something that cannot be sent as -`structuredContent`, the SDK logs a warning — the value is not silently dropped. - -A tool that wants to branch on the revision itself can read it from the injected `RequestContext`, see -[Client Communication](server-client-communication.md#client-gateway). - -[sep-2106]: https://modelcontextprotocol.io/specification/2026-07-28/server/tools#structured-content - -#### Error Handling - -Tool handlers can throw any exception, but the type determines how it's handled: - -- **`ToolCallException`**: Converted to JSON-RPC response with `CallToolResult` where `isError: true`, allowing the LLM to see the error message and self-correct -- **Any other exception**: Converted to JSON-RPC error response, but with a generic error message - -```php -use Mcp\Exception\ToolCallException; - -#[McpTool] -public function divideNumbers(float $a, float $b): float -{ - if ($b === 0.0) { - throw new ToolCallException('Division by zero is not allowed'); - } - - return $a / $b; -} - -#[McpTool] -public function processFile(string $filename): string -{ - if (!file_exists($filename)) { - throw new ToolCallException("File not found: {$filename}"); - } - - return file_get_contents($filename); -} -``` - -**Recommendation**: Use `ToolCallException` when you want to communicate specific errors to clients. Any other exception will still be converted to JSON-RPC compliant errors but with generic error messages. - - -## Resources - -Resources provide access to static data that clients can read. - -```php -use Mcp\Capability\Attribute\McpResource; - -class ConfigProvider -{ - /** - * Provides the current application configuration. - */ - #[McpResource(uri: 'config://app/settings', name: 'app_settings')] - public function getSettings(): array - { - return [ - 'version' => '1.0.0', - 'debug' => false, - 'features' => ['auth', 'logging'] - ]; - } -} -``` - -### Parameters - -- **`uri`** (required): Unique resource identifier. Must comply with [RFC 3986](https://datatracker.ietf.org/doc/html/rfc3986). -- **`name`** (optional): Short resource identifier. Defaults to method name if not provided. -- **`title`** (optional): Human-readable display title shown in client UI. Distinct from `name`. -- **`description`** (optional): Resource description. Defaults to docblock summary if not provided. -- **`mimeType`** (optional): MIME type of the resource content. -- **`size`** (optional): Size in bytes if known. -- **`annotations`** (optional): Additional metadata. -- **`icons`** (optional): Array of `Icon` objects for visual representation. -- **`meta`** (optional): Arbitrary key-value pairs for custom metadata. - -**Standard Protocol URI Schemes**: `https://` (web resources), `file://` (filesystem), `git://` (version control). -**Custom schemes**: `config://`, `data://`, `db://`, `api://` or any RFC 3986 compliant scheme. - -### Resource Return Values - -Resource handlers can return various data types that are automatically formatted into appropriate MCP resource content types. - -#### Supported Return Types - -```php -// String content - converted to text resource -public function getTextFile(): string -{ - return "File content here"; -} - -// Array content - converted to JSON -public function getConfig(): array -{ - return ['debug' => true, 'version' => '1.0']; -} - -// Stream resource - read and converted to blob -public function getImageStream(): resource -{ - return fopen('image.png', 'r'); -} - -// SplFileInfo - file content with MIME type detection -public function getFileInfo(): \SplFileInfo -{ - return new \SplFileInfo('document.pdf'); -} -``` - -**Explicit resource content types** - -```php -use Mcp\Schema\Content\{TextResourceContents, BlobResourceContents}; - -public function getExplicitText(): TextResourceContents -{ - return new TextResourceContents( - uri: 'config://app/settings', - mimeType: 'application/json', - text: json_encode(['setting' => 'value']) - ); -} - -public function getExplicitBlob(): BlobResourceContents -{ - return new BlobResourceContents( - uri: 'file://image.png', - mimeType: 'image/png', - blob: base64_encode(file_get_contents('image.png')) - ); -} -``` - -**Special Array Formats** - -```php -// Array with 'text' key - used as text content -public function getTextArray(): array -{ - return ['text' => 'Content here', 'mimeType' => 'text/plain']; -} - -// Array with 'blob' key - used as blob content -public function getBlobArray(): array -{ - return ['blob' => base64_encode($data), 'mimeType' => 'image/png']; -} - -// Multiple resource contents -public function getMultipleResources(): array -{ - return [ - new TextResourceContents('file://readme.txt', 'text/plain', 'README content'), - new TextResourceContents('file://config.json', 'application/json', '{"key": "value"}') - ]; -} -``` - -#### Error Handling - -Resource handlers can throw any exception, but the type determines how it's handled: - -- **`ResourceReadException`**: Converted to JSON-RPC error response with the actual exception message -- **Any other exception**: Converted to JSON-RPC error response, but with a generic error message - -```php -use Mcp\Exception\ResourceReadException; - -#[McpResource(uri: 'file://{path}')] -public function getFile(string $path): string -{ - if (!file_exists($path)) { - throw new ResourceReadException("File not found: {$path}"); - } - - if (!is_readable($path)) { - throw new ResourceReadException("File not readable: {$path}"); - } - - return file_get_contents($path); -} -``` - -**Recommendation**: Use `ResourceReadException` when you want to communicate specific errors to clients. Any other exception will still be converted to JSON-RPC compliant errors but with generic error messages. - -## Resource Templates - -Resource templates are **dynamic resources** that use parameterized URIs with variables. They follow all the same rules -as static resources (URI schemas, return values, MIME types, etc.) but accept variables using [RFC 6570 URI template syntax](https://datatracker.ietf.org/doc/html/rfc6570). - -```php -use Mcp\Capability\Attribute\McpResourceTemplate; - -class UserProvider -{ - /** - * Retrieves user profile information by ID. - */ - #[McpResourceTemplate( - uriTemplate: 'user://{userId}/profile/{section}', - name: 'user_profile', - description: 'User profile data by section', - mimeType: 'application/json' - )] - public function getUserProfile(string $userId, string $section): array - { - return $this->users[$userId][$section] ?? throw new \InvalidArgumentException("Profile section not found"); - } -} -``` - -### Parameters - -- **`uriTemplate`** (required): URI template with `{variables}` using RFC 6570 syntax. Must comply with RFC 3986. -- **`name`** (optional): Short resource template identifier. Defaults to method name if not provided. -- **`title`** (optional): Human-readable display title shown in client UI. Distinct from `name`. -- **`description`** (optional): Template description. Defaults to docblock summary if not provided. -- **`mimeType`** (optional): MIME type of the resource content. -- **`annotations`** (optional): Additional metadata. - -### Variable Rules - -1. **Variable names must match exactly** between URI template and method parameters -2. **Parameter order matters** - variables are passed in the order they appear in the URI template -3. **All variables are required** - no optional parameters supported -4. **Type hints work normally** - parameters can be typed (string, int, etc.) - -**Example mapping**: `user://123/profile/settings` → `getUserProfile("123", "settings")` - -## Prompts - -Prompts generate templates for AI interactions. - -```php -use Mcp\Capability\Attribute\McpPrompt; - -class PromptGenerator -{ - /** - * Generates a code review request prompt. - */ - #[McpPrompt(name: 'code_review')] - public function reviewCode(string $language, string $code, string $focus = 'general'): array - { - return [ - ['role' => 'system', 'content' => 'You are an expert code reviewer.'], - ['role' => 'user', 'content' => "Review this {$language} code focusing on {$focus}:\n\n```{$language}\n{$code}\n```"] - ]; - } -} -``` - -### Parameters - -- **`name`** (optional): Prompt identifier. Defaults to method name if not provided. -- **`title`** (optional): Human-readable display title shown in client UI. Distinct from `name`. -- **`description`** (optional): Prompt description. Defaults to docblock summary if not provided. -- **`icons`** (optional): Array of `Icon` objects for visual representation. -- **`meta`** (optional): Arbitrary key-value pairs for custom metadata. - -### Prompt Return Values - -Prompt handlers must return an array of message structures that are automatically formatted into MCP prompt messages. - -#### Supported Return Formats - -```php -// Array of message objects with role and content -public function basicPrompt(): array -{ - return [ - ['role' => 'assistant', 'content' => 'You are a helpful assistant'], - ['role' => 'user', 'content' => 'Hello, how are you?'] - ]; -} - -// Single message (automatically wrapped in array) -public function singleMessage(): array -{ - return [ - ['role' => 'user', 'content' => 'Write a poem about PHP'] - ]; -} - -// Associative array with user/assistant keys -public function userAssistantFormat(): array -{ - return [ - 'user' => 'Explain how arrays work in PHP', - 'assistant' => 'Arrays in PHP are ordered maps...' - ]; -} - -// Mixed content types in messages -use Mcp\Schema\Content\{TextContent, ImageContent}; - -public function mixedContent(): array -{ - return [ - [ - 'role' => 'user', - 'content' => [ - new TextContent('Analyze this image:'), - new ImageContent(data: $imageData, mimeType: 'image/png') - ] - ] - ]; -} - -// Using explicit PromptMessage objects -use Mcp\Schema\PromptMessage; -use Mcp\Schema\Enum\Role; - -public function explicitMessages(): array -{ - return [ - new PromptMessage(Role::Assistant, [new TextContent('System instructions')]), - new PromptMessage(Role::User, [new TextContent('User question')]) - ]; -} -``` - -The SDK automatically validates that all messages have valid roles and converts the result into the appropriate MCP prompt message format. - -#### Valid Message Roles - -- **`user`**: User input or questions -- **`assistant`**: Assistant responses/system - -#### Error Handling - -Prompt handlers can throw any exception, but the type determines how it's handled: -- **`PromptGetException`**: Converted to JSON-RPC error response with the actual exception message -- **Any other exception**: Converted to JSON-RPC error response, but with a generic error message - -```php -use Mcp\Exception\PromptGetException; - -#[McpPrompt] -public function generatePrompt(string $topic, string $style): array -{ - $validStyles = ['casual', 'formal', 'technical']; - - if (!in_array($style, $validStyles)) { - throw new PromptGetException( - "Invalid style '{$style}'. Must be one of: " . implode(', ', $validStyles) - ); - } - - return [ - ['role' => 'user', 'content' => "Write about {$topic} in a {$style} style"] - ]; -} -``` - -**Recommendation**: Use `PromptGetException` when you want to communicate specific errors to clients. Any other exception will still be converted to JSON-RPC compliant errors but with generic error messages. - -## Logging - -> **Deprecated** since protocol revision `2026-07-28` ([SEP-2577](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577)), earliest removal `2027-07-28`. Logging keeps working until then; new integrations should log to stderr (stdio) or use OpenTelemetry instead. - -The SDK provides support to send structured log messages to clients. All standard PSR-3 log levels are supported. -Level **warning** as the default level. - -### Usage - -The SDK automatically injects a `RequestContext` instance into handlers. This can be used to create a `ClientLogger`. - -```php -use Mcp\Capability\Logger\ClientLogger; -use Mcp\Server\RequestContext; - -#[McpTool] -public function processData(string $input, RequestContext $context): array { - $logger = $context->getClientLogger(); - - $logger->info('Processing started', ['input' => $input]); - $logger->warning('Deprecated API used'); - - // ... processing logic ... - - $logger->info('Processing completed'); - return ['result' => 'processed']; -} -``` - -## Completion Providers - -Completion providers help MCP clients offer auto-completion suggestions for Resource Templates and Prompts. Unlike Tools and static Resources (which can be listed via `tools/list` and `resources/list`), Resource Templates and Prompts have dynamic parameters that benefit from completion hints. - -### Completion Provider Types - -#### 1. Value Lists - -Provide a static list of possible values: - -```php -use Mcp\Capability\Attribute\CompletionProvider; - -#[McpPrompt] -public function generateContent( - #[CompletionProvider(values: ['blog', 'article', 'tutorial', 'guide'])] - string $contentType, - - #[CompletionProvider(values: ['beginner', 'intermediate', 'advanced'])] - string $difficulty -): array -{ - return [ - ['role' => 'user', 'content' => "Create a {$difficulty} level {$contentType}"] - ]; -} -``` - -#### 2. Enum Classes - -Use enum values for completion: - -```php -enum Priority: string -{ - case LOW = 'low'; - case MEDIUM = 'medium'; - case HIGH = 'high'; -} - -enum Status // Unit enum -{ - case DRAFT; - case PUBLISHED; - case ARCHIVED; -} - -#[McpResourceTemplate(uriTemplate: 'tasks/{taskId}')] -public function getTask( - string $taskId, - - #[CompletionProvider(enum: Priority::class)] // Uses backing values - string $priority, - - #[CompletionProvider(enum: Status::class)] // Uses case names - string $status -): array -{ - // Implementation -} -``` - -#### 3. Custom Provider Classes - -For dynamic completion logic: - -```php -use Mcp\Capability\Prompt\Completion\ProviderInterface; - -class UserIdCompletionProvider implements ProviderInterface -{ - public function __construct(private DatabaseService $db) {} - - public function getCompletions(string $currentValue): array - { - // Return dynamic completions based on current input - return $this->db->searchUserIds($currentValue); - } -} - -#[McpResourceTemplate(uriTemplate: 'user://{userId}/profile')] -public function getUserProfile( - #[CompletionProvider(provider: UserIdCompletionProvider::class)] - string $userId -): array -{ - // Implementation -} -``` - -**Provider Resolution:** -- **Class strings** (`Provider::class`) → Resolved from PSR-11 container -- **Instances** (`new Provider()`) → Used directly -- **Values** (`['a', 'b']`) → Wrapped in `ListCompletionProvider` -- **Enums** (`MyEnum::class`) → Wrapped in `EnumCompletionProvider` - -> **Important** -> -> Completion providers only offer **suggestions** to users. Users can still input any value, so **always validate -> parameters** in your handlers. Providers don't enforce validation - they're purely for UX improvement. - -## Schema Generation and Validation - -The SDK automatically generates JSON schemas for **tool parameters** using a sophisticated priority system. Schema -generation applies to both attribute-discovered and manually registered tools. - -### Schema Generation Priority - -The server follows this order of precedence: - -1. **`#[Schema]` attribute with `definition`** - Complete schema override (highest priority) -2. **Parameter-level `#[Schema]` attribute** - Parameter-specific enhancements -3. **Method-level `#[Schema]` attribute** - Method-wide configuration -4. **PHP type hints + docblocks** - Automatic inference (lowest priority) - -### Automatic Schema from PHP Types - -```php -#[McpTool] -public function processUser( - string $email, // Required string - int $age, // Required integer - ?string $name = null, // Optional string - bool $active = true // Boolean with default -): array -{ - // Schema auto-generated from method signature -} -``` - -### Parameter-Level Schema Enhancement - -Add validation rules to specific parameters: - -```php -use Mcp\Capability\Attribute\Schema; - -#[McpTool] -public function validateUser( - #[Schema(format: 'email')] - string $email, - - #[Schema(minimum: 18, maximum: 120)] - int $age, - - #[Schema( - pattern: '^[A-Z][a-z]+$', - description: 'Capitalized first name' - )] - string $firstName -): bool -{ - // PHP types provide base validation - // Schema attributes add constraints -} -``` - -### Method-Level Schema - -Add validation for complex object structures: - -```php -#[McpTool] -#[Schema( - properties: [ - 'userData' => [ - 'type' => 'object', - 'properties' => [ - 'name' => ['type' => 'string', 'minLength' => 2], - 'email' => ['type' => 'string', 'format' => 'email'], - 'age' => ['type' => 'integer', 'minimum' => 18] - ], - 'required' => ['name', 'email'] - ] - ], - required: ['userData'] -)] -public function createUser(array $userData): array -{ - // Method-level schema adds object structure validation - // PHP array type provides base type -} -``` - -### Complete Schema Override - -**Use sparingly** - bypasses all automatic inference: - -```php -#[McpTool] -#[Schema(definition: [ - 'type' => 'object', - 'properties' => [ - 'endpoint' => ['type' => 'string', 'format' => 'uri'], - 'method' => ['type' => 'string', 'enum' => ['GET', 'POST', 'PUT', 'DELETE']], - 'headers' => [ - 'type' => 'object', - 'patternProperties' => [ - '^[A-Za-z0-9-]+$' => ['type' => 'string'] - ] - ] - ], - 'required' => ['endpoint', 'method'] -])] -public function makeApiRequest(string $endpoint, string $method, array $headers): array -{ - // Complete definition override - PHP types ignored -} -``` - -**Warning:** Only use complete schema override if you're well-versed with JSON Schema specification and have complex -validation requirements that cannot be achieved through the priority system. - -## Discovery vs Manual Registration - -### Attribute-Based Discovery - -**Advantages:** -- Declarative and readable -- Automatic parameter inference -- DocBlock integration -- Type-safe by default -- Caching support - -**Example:** -```php -$server = Server::builder() - ->setDiscovery(__DIR__, ['.']) // Automatic discovery - ->build(); -``` - -### Manual Registration - -**Advantages:** -- Fine-grained control -- Runtime configuration -- Conditional registration -- External handler support - -**Example:** -```php -$server = Server::builder() - ->addTool([Calculator::class, 'add'], 'add_numbers') - ->addResource([Config::class, 'get'], 'config://app') - ->addPrompt([Prompts::class, 'email'], 'write_email') - ->build(); -``` - -For detailed information on manual registration, see [Server Builder](server-builder.md#manual-capability-registration). - -### Hybrid Approach - -Combine both methods for maximum flexibility: - -```php -$server = Server::builder() - ->setDiscovery(__DIR__, ['.']) // Discover most capabilities - ->addTool([ExternalService::class, 'process'], 'external') // Add specific ones - ->build(); -``` - -Manual registrations always take precedence over discovered elements with the same identifier. diff --git a/docs/authorization.md b/docs/run/authorization.md similarity index 95% rename from docs/authorization.md rename to docs/run/authorization.md index fa9ffd5d..0a329277 100644 --- a/docs/authorization.md +++ b/docs/run/authorization.md @@ -3,18 +3,6 @@ The PHP MCP SDK provides OAuth 2.1 authorization support for HTTP transports, implementing the [MCP Authorization specification](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization). -## Table of Contents - -- [Scope: what this SDK does and does not do](#scope-what-this-sdk-does-and-does-not-do) -- [Overview](#overview) -- [Quick Start](#quick-start) -- [Components](#components) -- [JWT Token Validation](#jwt-token-validation) -- [Protected Resource Metadata](#protected-resource-metadata) -- [Custom Token Validators](#custom-token-validators) -- [Scope-Based Access Control](#scope-based-access-control) -- [Examples](#examples) - ## Scope: what this SDK does and does not do The MCP server is an OAuth 2.1 **Resource Server**. It validates the tokens it receives and may @@ -99,7 +87,14 @@ $metadataMiddleware = new ProtectedResourceMetadataMiddleware( // 5. Create transport with middleware $transport = new StreamableHttpTransport( $request, - middlewares: [$metadataMiddleware, $authMiddleware], + middleware: [ + ...StreamableHttpTransport::defaultMiddleware(), + $metadataMiddleware, + $authMiddleware, + // Bridges the OAuth attributes onto the JSON-RPC request meta, which is + // what makes them reachable from a handler (see Scope-Based Access Control). + new OAuthRequestMetaMiddleware(), + ], ); // 6. Run server @@ -155,7 +150,7 @@ $validator = new JwtTokenValidator( audience: 'mcp-server', // Expected audience (string or array) jwksProvider: $jwksProvider, // JwksProviderInterface jwksUri: null, // Explicit JWKS URI (auto-discovered) - algorithms: ['RS256', 'RS384'], // Allowed algorithms + algorithms: ['RS256', 'RS384', 'RS512'], // Allowed algorithms (this is the default) scopeClaim: 'scope', // Claim name for scopes ); ``` @@ -363,7 +358,10 @@ AuthorizationResult::badRequest('invalid_request', 'Malformed header'); #[McpTool(name: 'admin_action')] public function adminAction(RequestContext $context): array { - $scopes = $context->getRequest()?->getAttribute('oauth.scopes') ?? []; + // The OAuth attributes arrive on the request meta, under the `oauth` key. + // This requires OAuthRequestMetaMiddleware in the transport's middleware stack. + $meta = $context->getRequest()->getMeta() ?? []; + $scopes = $meta['oauth']['oauth.scopes'] ?? []; if (!in_array('mcp:admin', $scopes, true)) { throw new \RuntimeException('Admin scope required'); diff --git a/docs/run/framework-integration.md b/docs/run/framework-integration.md new file mode 100644 index 00000000..95457a0c --- /dev/null +++ b/docs/run/framework-integration.md @@ -0,0 +1,202 @@ +# Framework integration + +The HTTP transport is a PSR-7 request handler, not a web server. This page +covers how it fits into an application you already have. + +## Architecture + +The HTTP transport doesn't run its own web server. Instead, it processes PSR-7 requests and returns PSR-7 responses that +your application can handle however it needs to: + +``` +Your Web App → PSR-7 Request → StreamableHttpTransport → PSR-7 Response → Your Web App +``` + +This design allows integration with any PHP framework or application that supports PSR-7. + +## Basic Usage (Standalone) + +Here's a simplified example using PSR-17 discovery and Laminas emitter: + +```php +use Http\Discovery\Psr17Factory; +use Mcp\Server; +use Mcp\Server\Transport\StreamableHttpTransport; +use Mcp\Server\Session\FileSessionStore; +use Laminas\HttpHandlerRunner\Emitter\SapiEmitter; + +$psr17Factory = new Psr17Factory(); +$request = $psr17Factory->createServerRequestFromGlobals(); + +$server = Server::builder() + ->setServerInfo('HTTP Server', '1.0.0') + ->setDiscovery(__DIR__, ['.']) + ->setSession(new FileSessionStore(__DIR__ . '/sessions')) // HTTP needs persistent sessions + ->build(); + +$transport = new StreamableHttpTransport($request); + +$response = $server->run($transport); + +(new SapiEmitter())->emit($response); +``` + +## Framework Integration + +### Symfony Integration + +First install the required PSR libraries: + +```bash +composer require symfony/psr-http-message-bridge nyholm/psr7 +``` + +Then create a controller that uses Symfony's PSR-7 bridge: + +> **Note**: This example assumes your MCP `Server` instance is configured in Symfony's service container. + +```php +// In a Symfony controller +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\Routing\Attribute\Route; +use Symfony\Bridge\PsrHttpMessage\Factory\PsrHttpFactory; +use Symfony\Bridge\PsrHttpMessage\Factory\HttpFoundationFactory; +use Mcp\Server; +use Mcp\Server\Transport\StreamableHttpTransport; + +class McpController +{ + #[Route('/mcp', name: 'mcp_endpoint')] + public function handle(Request $request, Server $server): Response + { + // Convert Symfony request to PSR-7 (PSR-17 factories auto-discovered) + $psrHttpFactory = new PsrHttpFactory(); + $httpFoundationFactory = new HttpFoundationFactory(); + $psrRequest = $psrHttpFactory->createRequest($request); + + // Process with MCP (factories auto-discovered) + $transport = new StreamableHttpTransport($psrRequest); + $psrResponse = $server->run($transport); + + // Convert PSR-7 response back to Symfony + return $httpFoundationFactory->createResponse($psrResponse); + } +} +``` + +### Laravel Integration + +First install the required PSR libraries: + +```bash +composer require symfony/psr-http-message-bridge nyholm/psr7 +``` + +Then create a controller that type-hints `ServerRequestInterface`: + +> **Note**: This example assumes your MCP `Server` instance is constructed and bound in a Laravel service provider for dependency injection. + +```php +// In a Laravel controller +use Psr\Http\Message\ServerRequestInterface; +use Psr\Http\Message\ResponseInterface; +use Mcp\Server; +use Mcp\Server\Transport\StreamableHttpTransport; + +class McpController +{ + public function handle(ServerRequestInterface $request, Server $server): ResponseInterface + { + // Create the MCP HTTP transport + $transport = new StreamableHttpTransport($request); + + // Process MCP request and return PSR-7 response + // Laravel automatically handles PSR-7 responses + return $server->run($transport); + } +} + +// Route registration +Route::any('/mcp', [McpController::class, 'handle']); +``` + +### Slim Framework Integration + +Slim Framework works natively with PSR-7. + +Create a route handler using Slim's built-in factories and container: + +```php +use Slim\Factory\AppFactory; +use Mcp\Server; +use Mcp\Server\Transport\StreamableHttpTransport; + +$app = AppFactory::create(); + +$app->any('/mcp', function ($request, $response) { + $server = Server::builder() + ->setServerInfo('My MCP Server', '1.0.0') + ->setDiscovery(__DIR__, ['.']) + ->build(); + + $transport = new StreamableHttpTransport($request); + + return $server->run($transport); +}); +``` + +## HTTP Method Handling + +The transport handles all HTTP methods automatically: + +- **POST**: Send MCP requests +- **GET**: Not implemented (returns 405) +- **DELETE**: End session +- **OPTIONS**: CORS preflight + +You should route **all methods** to your MCP endpoint, not just POST. + +## Session Management + +HTTP transport requires persistent sessions since PHP doesn't maintain state between requests. Unlike STDIO transport +where in-memory sessions work fine, HTTP transport needs a persistent session store: + +```php +use Mcp\Server\Session\FileSessionStore; + +// ✅ Good for HTTP +$server = Server::builder() + ->setSession(new FileSessionStore(__DIR__ . '/sessions')) + ->build(); + +// ❌ Not recommended for HTTP (sessions lost between requests) +$server = Server::builder() + ->setSession(new InMemorySessionStore()) + ->build(); +``` + +## Recommended Route + +It's recommended to mount the MCP endpoint at `/mcp`, but this is not enforced: + +```php +// Recommended +Route::any('/mcp', [McpController::class, 'handle']); + +// Also valid +Route::any('/', [McpController::class, 'handle']); +Route::any('/api/mcp', [McpController::class, 'handle']); +``` + +## Testing HTTP Transport + +Use the MCP Inspector to test HTTP servers: + +```bash +# Start your PHP server +php -S localhost:8000 server.php + +# Connect with MCP Inspector +npx @modelcontextprotocol/inspector http://localhost:8000 +``` diff --git a/docs/transports.md b/docs/run/http.md similarity index 50% rename from docs/transports.md rename to docs/run/http.md index ffa1496e..9fc143aa 100644 --- a/docs/transports.md +++ b/docs/run/http.md @@ -1,94 +1,4 @@ -# Transports - -Transports handle the communication layer between MCP servers and clients. The PHP MCP SDK provides two main transport -implementations: STDIO for command-line integration and HTTP for web-based communication. - -## Table of Contents - -- [Transport Overview](#transport-overview) -- [STDIO Transport](#stdio-transport) -- [HTTP Transport](#http-transport) -- [Choosing a Transport](#choosing-a-transport) - -## Transport Overview - -All transports implement the `TransportInterface` and follow the same basic pattern: - -```php -$server = Server::builder() - ->setServerInfo('My Server', '1.0.0') - ->setDiscovery(__DIR__, ['.']) - ->build(); - -$transport = new SomeTransport(); - -$result = $server->run($transport); // Blocks for STDIO, returns a response for HTTP -``` - -## STDIO Transport - -The STDIO transport communicates via standard input/output streams, ideal for command-line tools and MCP client integrations. - -```php -$transport = new StdioTransport( - input: STDIN, // Input stream (default: STDIN) - output: STDOUT, // Output stream (default: STDOUT) - logger: $logger // Optional PSR-3 logger -); -``` - -### Parameters - -- **`input`** (optional): Input stream resource. Defaults to `STDIN`. -- **`output`** (optional): Output stream resource. Defaults to `STDOUT`. -- **`logger`** (optional): `LoggerInterface` - PSR-3 logger for debugging. Defaults to `NullLogger`. - -> [!IMPORTANT] -> When using STDIO transport, **never** write to `STDOUT` in your handlers as it's reserved for JSON-RPC communication. -> Use `STDERR` for debugging instead. - -### Example Server Script - -```php -#!/usr/bin/env php -setServerInfo('STDIO Calculator', '1.0.0') - ->addTool(function(int $a, int $b): int { return $a + $b; }, 'add_numbers') - ->addTool(InvokableCalculator::class) - ->build(); - -$transport = new StdioTransport(); - -$status = $server->run($transport); - -exit($status); // 0 on clean shutdown, non-zero if STDIN errored -``` - -### Client Configuration - -For MCP clients like Claude Desktop: - -```json -{ - "mcpServers": { - "my-php-server": { - "command": "php", - "args": ["/absolute/path/to/server.php"] - } - } -} -``` - -## HTTP Transport +# HTTP Transport The HTTP transport was designed to sit between any PHP project, regardless of the HTTP implementation or how they receive and process requests and send responses. It provides a flexible architecture that can integrate with any PSR-7 compatible application. @@ -105,7 +15,7 @@ $transport = new StreamableHttpTransport( ); ``` -### Parameters +## Parameters - **`request`** (required): `ServerRequestInterface` - The incoming PSR-7 HTTP request - **`responseFactory`** (optional): `ResponseFactoryInterface` - PSR-17 factory for creating HTTP responses. Auto-discovered if not provided. @@ -114,7 +24,7 @@ $transport = new StreamableHttpTransport( - **`middleware`** (optional): `iterable|null` - PSR-15 middleware chain. `null` (omitted) installs the [default stack](#default-middleware). `[]` disables all defaults — useful when the surrounding application already handles CORS, host validation, etc. - **`maxBodyBytes`** (optional): `int` - Upper bound on the POST request body read, in bytes. Defaults to 4 MiB (`StreamableHttpTransport::DEFAULT_MAX_BODY_BYTES`). See [Request Body Size Limit](#request-body-size-limit). -### PSR-17 Auto-Discovery +## PSR-17 Auto-Discovery The transport automatically discovers PSR-17 factory implementations from these popular packages: @@ -138,7 +48,7 @@ $psr17Factory = new Psr17Factory(); $transport = new StreamableHttpTransport($request, $psr17Factory, $psr17Factory); ``` -### Default Middleware +## Default Middleware When the `middleware` argument is omitted (or set to `null`), the transport installs a secure default stack: @@ -146,6 +56,7 @@ When the `middleware` argument is omitted (or set to `null`), the transport inst |-------|------------|---------| | 1 | `CorsMiddleware` | Applies CORS headers to every response. By default does **not** set `Access-Control-Allow-Origin` (cross-origin requests are blocked). | | 2 | `DnsRebindingProtectionMiddleware` | Validates `Origin`/`Host` against an allowlist. Defaults to localhost variants only. | +| 3 | `ProtocolVersionMiddleware` | Rejects requests carrying an unsupported `MCP-Protocol-Version` header with `400 Bad Request`. | ```php // Zero-config, secure-by-default — local servers get full protection automatically. @@ -158,14 +69,7 @@ The default stack can be inspected and recomposed via the public factory: $middleware = StreamableHttpTransport::defaultMiddleware(); ``` -These run at the edge, before the request's protocol era is known, because what they enforce is true of -both eras. `ProtocolVersionMiddleware` is not in that stack: the `MCP-Protocol-Version` header rule belongs -to the handshake era, so the transport applies it only to requests it classified as handshake-era traffic, -and the modern leg answers for its own revisions. It is available as -`StreamableHttpTransport::handshakeMiddleware()` and is applied whether or not you replace the edge stack. -See [Serving both eras](stateless-lifecycle.md#serving-both-eras). - -### CORS Configuration +## CORS Configuration CORS is handled by `CorsMiddleware`. To enable cross-origin browser requests, configure it explicitly and pass it in place of (or alongside) the defaults: @@ -203,13 +107,13 @@ so shared caches/CDNs do not serve a response generated for one origin to a requ Headers already present on a response (e.g. set by inner middleware) are preserved — `CorsMiddleware` only adds defaults when they are absent. -> [!IMPORTANT] -> `Access-Control-Allow-Origin: *` is incompatible with credentialed browser requests (those carrying -> `Authorization`, cookies, or client certificates). If your MCP server runs OAuth/Bearer auth and serves -> a browser client, configure `allowedOrigins` with the explicit origin(s) you trust rather than `['*']`. -> The middleware reflects the matching origin verbatim, which is the form browsers accept with credentials. +!!! warning + `Access-Control-Allow-Origin: *` is incompatible with credentialed browser requests (those carrying + `Authorization`, cookies, or client certificates). If your MCP server runs OAuth/Bearer auth and serves + a browser client, configure `allowedOrigins` with the explicit origin(s) you trust rather than `['*']`. + The middleware reflects the matching origin verbatim, which is the form browsers accept with credentials. -### DNS Rebinding Protection +## DNS Rebinding Protection `DnsRebindingProtectionMiddleware` validates the `Origin` header against an allowlist (falling back to `Host` when `Origin` is absent). The default allowlist is localhost-only: @@ -223,7 +127,7 @@ new DnsRebindingProtectionMiddleware(allowedHosts: ['myapp.local', 'mcp.internal If the server is fronted by a reverse proxy that already validates `Host`, drop this middleware from the chain or supply a permissive allowlist. -### Protocol Version Validation +## Protocol Version Validation `ProtocolVersionMiddleware` rejects requests whose `MCP-Protocol-Version` header is not in the SDK's supported set with `400 Bad Request`. Requests without the header pass through, since the `initialize` round-trip and some @@ -248,7 +152,7 @@ first place. Being separate also means it is unaffected by `setProtocolVersion() the set it was constructed with, not against the revision a given session negotiated, so a server that pins the handshake has to pass that revision here as well. -### Request Body Size Limit +## Request Body Size Limit `StreamableHttpTransport` caps the POST body it reads to guard against memory exhaustion from an oversized or unbounded (chunked) payload. The default cap is 4 MiB. A body over the cap is rejected with `413` and never reaches @@ -265,7 +169,7 @@ When the request stream advertises a size, the transport rejects it up-front. Ot unknown size) the body is read incrementally and aborted as soon as it crosses the cap, so an unbounded stream cannot exhaust memory. A value below `1` throws `InvalidArgumentException`. -### JSON-RPC Batch Size Limit +## JSON-RPC Batch Size Limit A JSON-RPC batch (top-level array) is capped at 100 messages by default. Oversized batches are rejected before any message is constructed, so a single small request cannot amplify into arbitrarily many operations. The cap lives on @@ -282,7 +186,7 @@ is a batch. Scalars, empty payloads, and non-object batch elements are returned entries (the existing per-message error contract), not parse errors or crashes. A `maxBatchSize` below `1` throws `InvalidArgumentException`. -### Custom PSR-15 Middleware +## Custom PSR-15 Middleware `StreamableHttpTransport` accepts any PSR-15 middleware chain. To extend the defaults, spread them and append your own middleware — the defaults stay outermost so CORS headers are applied to every response, including @@ -348,207 +252,3 @@ $transport = new StreamableHttpTransport( middleware: [new AuthMiddleware($responseFactory)], ); ``` - -### Architecture - -The HTTP transport doesn't run its own web server. Instead, it processes PSR-7 requests and returns PSR-7 responses that -your application can handle however it needs to: - -``` -Your Web App → PSR-7 Request → StreamableHttpTransport → PSR-7 Response → Your Web App -``` - -This design allows integration with any PHP framework or application that supports PSR-7. - -### Basic Usage (Standalone) - -Here's a simplified example using PSR-17 discovery and Laminas emitter: - -```php -use Http\Discovery\Psr17Factory; -use Mcp\Server; -use Mcp\Server\Transport\StreamableHttpTransport; -use Mcp\Server\Session\FileSessionStore; -use Laminas\HttpHandlerRunner\Emitter\SapiEmitter; - -$psr17Factory = new Psr17Factory(); -$request = $psr17Factory->createServerRequestFromGlobals(); - -$server = Server::builder() - ->setServerInfo('HTTP Server', '1.0.0') - ->setDiscovery(__DIR__, ['.']) - ->setSession(new FileSessionStore(__DIR__ . '/sessions')) // HTTP needs persistent sessions - ->build(); - -$transport = new StreamableHttpTransport($request); - -$response = $server->run($transport); - -(new SapiEmitter())->emit($response); -``` - -### Framework Integration - -#### Symfony Integration - -First install the required PSR libraries: - -```bash -composer require symfony/psr-http-message-bridge nyholm/psr7 -``` - -Then create a controller that uses Symfony's PSR-7 bridge: - -> **Note**: This example assumes your MCP `Server` instance is configured in Symfony's service container. - -```php -// In a Symfony controller -use Symfony\Component\HttpFoundation\Request; -use Symfony\Component\HttpFoundation\Response; -use Symfony\Component\Routing\Attribute\Route; -use Symfony\Bridge\PsrHttpMessage\Factory\PsrHttpFactory; -use Symfony\Bridge\PsrHttpMessage\Factory\HttpFoundationFactory; -use Mcp\Server; -use Mcp\Server\Transport\StreamableHttpTransport; - -class McpController -{ - #[Route('/mcp', name: 'mcp_endpoint')] - public function handle(Request $request, Server $server): Response - { - // Convert Symfony request to PSR-7 (PSR-17 factories auto-discovered) - $psrHttpFactory = new PsrHttpFactory(); - $httpFoundationFactory = new HttpFoundationFactory(); - $psrRequest = $psrHttpFactory->createRequest($request); - - // Process with MCP (factories auto-discovered) - $transport = new StreamableHttpTransport($psrRequest); - $psrResponse = $server->run($transport); - - // Convert PSR-7 response back to Symfony - return $httpFoundationFactory->createResponse($psrResponse); - } -} -``` - -#### Laravel Integration - -First install the required PSR libraries: - -```bash -composer require symfony/psr-http-message-bridge nyholm/psr7 -``` - -Then create a controller that type-hints `ServerRequestInterface`: - -> **Note**: This example assumes your MCP `Server` instance is constructed and bound in a Laravel service provider for dependency injection. - -```php -// In a Laravel controller -use Psr\Http\Message\ServerRequestInterface; -use Psr\Http\Message\ResponseInterface; -use Mcp\Server; -use Mcp\Server\Transport\StreamableHttpTransport; - -class McpController -{ - public function handle(ServerRequestInterface $request, Server $server): ResponseInterface - { - // Create the MCP HTTP transport - $transport = new StreamableHttpTransport($request); - - // Process MCP request and return PSR-7 response - // Laravel automatically handles PSR-7 responses - return $server->run($transport); - } -} - -// Route registration -Route::any('/mcp', [McpController::class, 'handle']); -``` - -#### Slim Framework Integration - -Slim Framework works natively with PSR-7. - -Create a route handler using Slim's built-in factories and container: - -```php -use Slim\Factory\AppFactory; -use Mcp\Server; -use Mcp\Server\Transport\StreamableHttpTransport; - -$app = AppFactory::create(); - -$app->any('/mcp', function ($request, $response) { - $server = Server::builder() - ->setServerInfo('My MCP Server', '1.0.0') - ->setDiscovery(__DIR__, ['.']) - ->build(); - - $transport = new StreamableHttpTransport($request); - - return $server->run($transport); -}); -``` - -### HTTP Method Handling - -The transport handles all HTTP methods automatically: - -- **POST**: Send MCP requests -- **GET**: Not implemented (returns 405) -- **DELETE**: End session -- **OPTIONS**: CORS preflight - -You should route **all methods** to your MCP endpoint, not just POST. - -### Session Management - -HTTP transport requires persistent sessions since PHP doesn't maintain state between requests. Unlike STDIO transport -where in-memory sessions work fine, HTTP transport needs a persistent session store: - -```php -use Mcp\Server\Session\FileSessionStore; - -// ✅ Good for HTTP -$server = Server::builder() - ->setSession(new FileSessionStore(__DIR__ . '/sessions')) - ->build(); - -// ❌ Not recommended for HTTP (sessions lost between requests) -$server = Server::builder() - ->setSession(new InMemorySessionStore()) - ->build(); -``` - -### Recommended Route - -It's recommended to mount the MCP endpoint at `/mcp`, but this is not enforced: - -```php -// Recommended -Route::any('/mcp', [McpController::class, 'handle']); - -// Also valid -Route::any('/', [McpController::class, 'handle']); -Route::any('/api/mcp', [McpController::class, 'handle']); -``` - -### Testing HTTP Transport - -Use the MCP Inspector to test HTTP servers: - -```bash -# Start your PHP server -php -S localhost:8000 server.php - -# Connect with MCP Inspector -npx @modelcontextprotocol/inspector http://localhost:8000 -``` - -## Choosing a Transport - -The choice between STDIO and HTTP transport depends on the client you want to integrate with. -If you are integrating with a client that is running **locally** (like Claude Desktop), use STDIO. -If you are building a server in a distributed environment and need to integrate with a **remote** client, use Streamable HTTP. diff --git a/docs/run/index.md b/docs/run/index.md new file mode 100644 index 00000000..2b69977f --- /dev/null +++ b/docs/run/index.md @@ -0,0 +1,35 @@ +# Running your server + +`Server::builder()` configures a server; `run()` puts it on a transport and starts +answering. Every transport implements `TransportInterface` and is used the same way: + +```php +$server = Server::builder() + ->setServerInfo('My Server', '1.0.0') + ->setDiscovery(__DIR__, ['.']) + ->build(); + +$transport = new SomeTransport(); + +$result = $server->run($transport); // Blocks for STDIO, returns a response for HTTP +``` + +## Choosing a transport + +The choice depends on the client you want to integrate with: + +* The client runs **locally** and launches your server as a subprocess (Claude Desktop, + most editors) → **[STDIO](stdio.md)**. +* The client is **remote**, or your server lives in a web application → + **[HTTP](http.md)**, the Streamable HTTP transport. + +The rest of this section: + +* **[Server builder](server-builder.md)** — every configuration knob: server info, + discovery, dependency injection, logging, pagination. +* **[Framework integration](framework-integration.md)** — mounting the HTTP transport in + a Symfony, Laravel, or Slim application, or running it standalone. +* **[Sessions](sessions.md)** — where per-client state lives, which matters as soon as + you serve HTTP from more than one process. +* **[Authorization](authorization.md)** — validating OAuth 2 access tokens in front of + the HTTP transport. diff --git a/docs/run/server-builder.md b/docs/run/server-builder.md new file mode 100644 index 00000000..17aa602f --- /dev/null +++ b/docs/run/server-builder.md @@ -0,0 +1,262 @@ +# Server builder + +The server `Builder` is a fluent builder class that simplifies the creation and configuration of an MCP server instance. +It provides methods for setting server information, configuring discovery, registering capabilities, and customizing +various aspects of the server behavior. + +## Basic Usage + +There are two ways to obtain a server builder instance: + +### Method 1: Static Builder Method (Recommended) + +```php +use Mcp\Server; + +$server = Server::builder() + ->setServerInfo('My MCP Server', '1.0.0') + ->setDiscovery(__DIR__, ['.']) + ->build(); +``` + +### Method 2: Direct Instantiation + +```php +use Mcp\Server\Builder; + +$server = (new Builder()) + ->setServerInfo('My MCP Server', '1.0.0') + ->setDiscovery(__DIR__, ['.']) + ->build(); +``` + +Both methods return a `Builder` instance that you can configure with fluent methods. The `build()` method returns the +final `Server` instance ready for use. + +## Server Configuration + +### Server Information + +Set the server's identity with name, version, and optional description: + +```php +use Mcp\Schema\Icon; +use Mcp\Server; + +$server = Server::builder() + ->setServerInfo( + name: 'Calculator Server', + version: '1.2.0', + description: 'Advanced mathematical calculations', + icons: [new Icon('https://example.com/icon.png', 'image/png', ['64x64'])], + websiteUrl: 'https://example.com', + ); +``` + +**Parameters:** +- `$name` (string): The server name +- `$version` (string): Version string (semantic versioning recommended) +- `$description` (string|null): Optional description +- `$icons` (Icon[]|null): Optional array of server icons +- `$websiteUrl` (string|null): Optional server website URL + +### Pagination Limit + +Configure the maximum number of items returned in paginated responses: + +```php +$server = Server::builder() + ->setPaginationLimit(100); // Default: 50 +``` + +### Instructions + +Provide hints to help AI models understand how to use your server: + +```php +$server = Server::builder() + ->setInstructions('This calculator supports basic arithmetic operations. Use the calculate tool for math operations and check the config resource for current settings.'); +``` + +## Discovery Configuration + +**Required when using MCP attributes.** If you're using PHP attributes (`#[McpTool]`, `#[McpResource]`, `#[McpResourceTemplate]`, `#[McpPrompt]`) to define your MCP elements, you **MUST** configure discovery to tell the server where to look for these attributes. + +```php +$server = Server::builder() + ->setDiscovery( + basePath: __DIR__, + scanDirs: ['.', 'src', 'lib'], // Where to look for MCP attributes + excludeDirs: ['vendor', 'tests'], // Where NOT to look + cache: $cacheInstance, // Optional: cache discovered elements + namePatterns: ['*.php', '*.inc'], // Optional: list of filename patterns to match + ); +``` + +**Parameters:** +- `$basePath` (string): Base directory for discovery (typically `__DIR__`) +- `$scanDirs` (array): Directories to recursively scan for `#[McpTool]`, `#[McpResource]`, etc. All subdirectories are included. (default: `['.', 'src']`) +- `$excludeDirs` (array): Directory names to exclude **within** the scanned directories during recursive scanning +- `$cache` (CacheInterface|null): Optional PSR-16 cache to store discovered elements for performance +- `$namePatterns` (array): Optional list of patterns (regexp, glob, or string) for file names (default: `['*.php']`) + +**Basic Discovery (scans current directory and `src/`):** +```php +$server = Server::builder() + ->setDiscovery(__DIR__) // Minimal setup + ->build(); +``` + +**Production Setup with Caching:** +```php +use Symfony\Component\Cache\Adapter\FilesystemAdapter; +use Symfony\Component\Cache\Psr16Cache; + +// Cache discovered elements to avoid filesystem scanning on every server start +$cache = new Psr16Cache(new FilesystemAdapter('mcp-discovery')); + +$server = Server::builder() + ->setDiscovery( + basePath: __DIR__, + scanDirs: ['src', 'lib'], // Scan these directories recursively + excludeDirs: ['vendor', 'tests', 'temp'], // Skip these directory names within scanned dirs + cache: $cache // Cache for performance + ) + ->build(); +``` + +**How `excludeDirs` works:** +- If scanning `src/` and there's `src/vendor/`, it will be excluded +- If scanning `lib/` and there's `lib/tests/`, it will be excluded +- But if `vendor/` and `tests/` are at the same level as `src/`, they're not scanned anyway (not in `scanDirs`) + +> **Performance**: Always use a cache in production. The first run scans and caches all discovered MCP elements, making +> subsequent server startups nearly instantaneous. + +## Service Dependencies + +### Container + +The container is used to resolve handlers and their dependencies when handlers inject dependencies in their constructors. +The SDK includes a basic container with simple auto-wiring capabilities. + +```php +use Mcp\Capability\Registry\Container; + +// Use the default basic container +$container = new Container(); +$container->set(DatabaseService::class, new DatabaseService($pdo)); +$container->set(\PDO::class, $pdo); + +$server = Server::builder() + ->setContainer($container) + ->build(); +``` + +**Basic Container Features:** +- Supports constructor auto-wiring for classes with parameterless constructors +- Resolves dependencies where all parameters are type-hinted classes/interfaces known to the container +- Supports parameters with default values +- Does NOT support scalar/built-in type injection without defaults +- Detects circular dependencies + +You can also use any PSR-11 compatible container (Symfony DI, PHP-DI, Laravel Container, etc.). + +### Logger + +Provide a PSR-3 logger instance for internal server logging (request/response processing, errors, session management, transport events): + +```php +use Monolog\Logger; +use Monolog\Handler\StreamHandler; + +$logger = new Logger('mcp-server'); +$logger->pushHandler(new StreamHandler('mcp.log', Logger::INFO)); + +$server = Server::builder() + ->setLogger($logger); +``` + +### Event Dispatcher + +Configure event dispatching: + +```php +$server = Server::builder() + ->setEventDispatcher($eventDispatcher); +``` + +## Complete Example + +Here's a comprehensive example showing all major configuration options: + +```php +use Mcp\Server; +use Mcp\Server\Session\FileSessionStore; +use Mcp\Capability\Registry\Container; +use Symfony\Component\Cache\Adapter\FilesystemAdapter; +use Symfony\Component\Cache\Psr16Cache; +use Monolog\Logger; +use Monolog\Handler\StreamHandler; + +// Setup dependencies +$logger = new Logger('mcp-server'); +$logger->pushHandler(new StreamHandler('mcp.log', Logger::INFO)); + +$cache = new Psr16Cache(new FilesystemAdapter('mcp-discovery')); +$sessionStore = new FileSessionStore(__DIR__ . '/sessions'); + +// Setup container with dependencies +$container = new Container(); +$container->set(\PDO::class, new \PDO('sqlite::memory:')); +$container->set(DatabaseService::class, new DatabaseService($container->get(\PDO::class))); + +// Build server +$server = Server::builder() + // Server identity + ->setServerInfo('Advanced Calculator', '2.1.0') + + // Performance and behavior + ->setPaginationLimit(100) + ->setInstructions('Use calculate tool for math operations. Check config resource for current settings.') + + // Discovery with caching + ->setDiscovery(__DIR__, ['src'], ['vendor', 'tests'], $cache) + + // Session management + ->setSession($sessionStore) + + // Services + ->setLogger($logger) + ->setContainer($container) + + // Manual capability registration + ->addTool([Calculator::class, 'advancedCalculation'], 'advanced_calc') + ->addResource([Config::class, 'getSettings'], 'config://app/settings', 'app_settings') + + // Build the server + ->build(); +``` + +## Method Reference + +| Method | Parameters | Description | +|--------|------------|-------------| +| `setServerInfo()` | name, version, description? | Set server identity | +| `setPaginationLimit()` | limit | Set max items per page | +| `setInstructions()` | instructions | Set usage instructions | +| `setDiscovery()` | basePath, scanDirs?, excludeDirs?, cache? | Configure attribute discovery | +| `setSession()` | sessionStore?, sessionManager?, gcProbability?, gcDivisor? | Configure session management | +| `setLogger()` | logger | Set PSR-3 logger | +| `setContainer()` | container | Set PSR-11 container | +| `setEventDispatcher()` | dispatcher | Set PSR-14 event dispatcher | +| `addRequestHandler()` | handler | Prepend a single custom request handler | +| `addRequestHandlers()` | handlers | Prepend multiple custom request handlers | +| `addNotificationHandler()` | handler | Prepend a single custom notification handler | +| `addNotificationHandlers()` | handlers | Prepend multiple custom notification handlers | +| `addTool()` | handler, name?, title?, description?, annotations?, inputSchema?, ... | Register tool | +| `addResource()` | handler, uri, name?, title?, description?, mimeType?, size?, annotations?, icons?, meta? | Register resource | +| `addResourceTemplate()` | handler, uriTemplate, name?, title?, description?, mimeType?, annotations?, meta? | Register resource template | +| `addPrompt()` | handler, name?, title?, description?, icons?, meta? | Register prompt | +| `add()` | definition, handler | Register an element from a schema VO + handler pair | +| `build()` | - | Create the server instance | diff --git a/docs/run/sessions.md b/docs/run/sessions.md new file mode 100644 index 00000000..929c0233 --- /dev/null +++ b/docs/run/sessions.md @@ -0,0 +1,122 @@ +# Session Management + +Configure session storage and lifecycle. By default, the SDK uses `InMemorySessionStore`: + +```php +use Mcp\Server\Session\FileSessionStore; +use Mcp\Server\Session\InMemorySessionStore; +use Mcp\Server\Session\Psr16SessionStore; +use Symfony\Component\Cache\Psr16Cache; +use Symfony\Component\Cache\Adapter\RedisAdapter; + +// Override with file-based storage +$server = Server::builder() + ->setSession(new FileSessionStore(__DIR__ . '/sessions')) + ->build(); + +// Override with in-memory storage and custom TTL +$server = Server::builder() + ->setSession(new InMemorySessionStore(3600)) + ->build(); + +// Override with PSR-16 cache-based storage +// Requires psr/simple-cache and symfony/cache (or any other PSR-16 implementation) +// composer require psr/simple-cache symfony/cache +$redisAdapter = new RedisAdapter( + RedisAdapter::createConnection('redis://localhost:6379'), + 'mcp_sessions' +); + +$server = Server::builder() + ->setSession(new Psr16SessionStore( + cache: new Psr16Cache($redisAdapter), + prefix: 'mcp-', + ttl: 3600 + )) + ->build(); +``` + +## Garbage Collection Configuration + +The SDK periodically runs garbage collection to clean up expired sessions, similar to PHP's native +`session.gc_probability` and `session.gc_divisor` settings. The probability that GC runs on any given +request is `gcProbability / gcDivisor`. + +```php +// Default: 1/100 (1% chance per request) +$server = Server::builder() + ->setSession(new FileSessionStore(__DIR__ . '/sessions')) + ->build(); + +// Higher frequency: 1/10 (10% chance per request) +$server = Server::builder() + ->setSession( + new FileSessionStore(__DIR__ . '/sessions'), + gcProbability: 1, + gcDivisor: 10, + ) + ->build(); + +// Run GC on every request +$server = Server::builder() + ->setSession(gcProbability: 1, gcDivisor: 1) + ->build(); + +// Disable GC entirely (e.g. when using an external cleanup process) +$server = Server::builder() + ->setSession(gcProbability: 0) + ->build(); +``` + +**Parameters:** +- `$gcProbability` (int): The numerator of the GC probability fraction (default: `1`). Set to `0` to disable GC. +- `$gcDivisor` (int): The denominator of the GC probability fraction (default: `100`). Must be >= 1. + +> **Note**: When providing a custom `SessionManagerInterface` via the `$sessionManager` parameter, +> the `gcProbability` and `gcDivisor` settings are ignored — you control GC behavior in your own implementation. + +**Available Session Stores:** +- `InMemorySessionStore`: Fast in-memory storage (default) +- `FileSessionStore`: Persistent file-based storage +- `Psr16SessionStore`: PSR-16 compliant cache-based storage + +**Custom Session Stores:** + +Implement `SessionStoreInterface` to create custom session storage: + +```php +use Mcp\Server\Session\SessionStoreInterface; +use Symfony\Component\Uid\Uuid; + +class RedisSessionStore implements SessionStoreInterface +{ + public function __construct(private $redis, private int $ttl = 3600) {} + + public function exists(Uuid $id): bool + { + return $this->redis->exists($id->toRfc4122()); + } + + public function read(Uuid $sessionId): string|false + { + $data = $this->redis->get($sessionId->toRfc4122()); + return $data !== false ? $data : false; + } + + public function write(Uuid $sessionId, string $data): bool + { + return $this->redis->setex($sessionId->toRfc4122(), $this->ttl, $data); + } + + public function destroy(Uuid $sessionId): bool + { + return $this->redis->del($sessionId->toRfc4122()) > 0; + } + + public function gc(): array + { + // Redis handles TTL automatically + return []; + } +} +``` diff --git a/docs/run/stdio.md b/docs/run/stdio.md new file mode 100644 index 00000000..24d40302 --- /dev/null +++ b/docs/run/stdio.md @@ -0,0 +1,62 @@ +# STDIO Transport + +The STDIO transport communicates via standard input/output streams, ideal for command-line tools and MCP client integrations. + +```php +$transport = new StdioTransport( + input: STDIN, // Input stream (default: STDIN) + output: STDOUT, // Output stream (default: STDOUT) + logger: $logger // Optional PSR-3 logger +); +``` + +## Parameters + +- **`input`** (optional): Input stream resource. Defaults to `STDIN`. +- **`output`** (optional): Output stream resource. Defaults to `STDOUT`. +- **`logger`** (optional): `LoggerInterface` - PSR-3 logger for debugging. Defaults to `NullLogger`. + +!!! warning + When using STDIO transport, **never** write to `STDOUT` in your handlers as it's reserved for JSON-RPC communication. + Use `STDERR` for debugging instead. + +## Example Server Script + +```php-file +#!/usr/bin/env php +setServerInfo('STDIO Calculator', '1.0.0') + ->addTool(function(int $a, int $b): int { return $a + $b; }, 'add_numbers') + ->addTool(InvokableCalculator::class) + ->build(); + +$transport = new StdioTransport(); + +$status = $server->run($transport); + +exit($status); // listen() returns 0 when the input stream closes +``` + +## Client Configuration + +For MCP clients like Claude Desktop: + +```json +{ + "mcpServers": { + "my-php-server": { + "command": "php", + "args": ["/absolute/path/to/server.php"] + } + } +} +``` diff --git a/docs/server-builder.md b/docs/server-builder.md deleted file mode 100644 index 6e1cee7f..00000000 --- a/docs/server-builder.md +++ /dev/null @@ -1,747 +0,0 @@ -# Server Builder - -The server `Builder` is a fluent builder class that simplifies the creation and configuration of an MCP server instance. -It provides methods for setting server information, configuring discovery, registering capabilities, and customizing -various aspects of the server behavior. - -## Table of Contents - -- [Basic Usage](#basic-usage) -- [Server Configuration](#server-configuration) -- [Protocol Version Negotiation](#protocol-version-negotiation) -- [Discovery Configuration](#discovery-configuration) -- [Session Management](#session-management) -- [Manual Capability Registration](#manual-capability-registration) -- [Service Dependencies](#service-dependencies) -- [Custom Message Handlers](#custom-message-handlers) -- [Complete Example](#complete-example) -- [Method Reference](#method-reference) - -## Basic Usage - -There are two ways to obtain a server builder instance: - -### Method 1: Static Builder Method (Recommended) - -```php -use Mcp\Server; - -$server = Server::builder() - ->setServerInfo('My MCP Server', '1.0.0') - ->setDiscovery(__DIR__, ['.']) - ->build(); -``` - -### Method 2: Direct Instantiation - -```php -use Mcp\Server\Builder; - -$server = (new Builder()) - ->setServerInfo('My MCP Server', '1.0.0') - ->setDiscovery(__DIR__, ['.']) - ->build(); -``` - -Both methods return a `Builder` instance that you can configure with fluent methods. The `build()` method returns the -final `Server` instance ready for use. - -## Server Configuration - -### Server Information - -Set the server's identity with name, version, and optional description: - -```php -use Mcp\Schema\Icon; -use Mcp\Server; - -$server = Server::builder() - ->setServerInfo( - name: 'Calculator Server', - version: '1.2.0', - description: 'Advanced mathematical calculations', - icons: [new Icon('https://example.com/icon.png', 'image/png', ['64x64'])], - websiteUrl: 'https://example.com' - '); -``` - -**Parameters:** -- `$name` (string): The server name -- `$version` (string): Version string (semantic versioning recommended) -- `$description` (string|null): Optional description -- `$icons` (Icon[]|null): Optional array of server icons -- `$websiteUrl` (string|null): Optional server website URL - -### Pagination Limit - -Configure the maximum number of items returned in paginated responses: - -```php -$server = Server::builder() - ->setPaginationLimit(100); // Default: 50 -``` - -### Instructions - -Provide hints to help AI models understand how to use your server: - -```php -$server = Server::builder() - ->setInstructions('This calculator supports basic arithmetic operations. Use the calculate tool for math operations and check the config resource for current settings.'); -``` - -### Protocol Version - -By default the server negotiates the protocol revision with each client during the `initialize` handshake, and you do -not need to configure anything. See [Protocol Version Negotiation](#protocol-version-negotiation) below for how that -negotiation resolves, and for what `setProtocolVersion()` changes: - -```php -use Mcp\Schema\Enum\ProtocolVersion; - -$server = Server::builder() - ->setProtocolVersion(ProtocolVersion::V2025_06_18); -``` - -## Protocol Version Negotiation - -MCP revisions are identified by a date string such as `2025-11-25`. The client names the revision it wants to speak in -its `initialize` request, and the server answers with the revision the connection will actually use. Both sides -disconnect if they cannot agree. This follows the -[protocol version negotiation](https://modelcontextprotocol.io/specification/draft/basic/versioning#protocol-version-negotiation) -section of the specification. - -The SDK's known revisions live in `Mcp\Schema\Enum\ProtocolVersion`, declared oldest to newest: - -```php -use Mcp\Schema\Enum\ProtocolVersion; - -ProtocolVersion::latestHandshake(); // newest revision reachable via `initialize` -ProtocolVersion::handshakeVersions(); // every revision the server will negotiate, oldest first -ProtocolVersion::V2025_11_25->isAtLeast(ProtocolVersion::V2025_06_18); // true -``` - -Comparisons go through declaration order rather than string collation. The identifiers happen to be ISO dates today, -but they are an enumerated set rather than an ordered scalar, so nothing should assume they sort chronologically. - -### How the server answers - -| Client requests | Server responds with | -| --- | --- | -| A revision the server supports | That same revision | -| An unknown or malformed revision | `ProtocolVersion::latestHandshake()` as a counter-offer | -| A modern revision such as `2026-07-28` | `ProtocolVersion::latestHandshake()` as a counter-offer | - -A counter-offer is not an error: the client decides whether it can continue on the offered revision or must close the -connection. The negotiated revision is stored on the session under `protocol_version`. - -The last row is not a rejection of an unknown revision — the SDK knows `2026-07-28`, it just cannot be reached through -this handshake. The modern era replaced `initialize` with per-request metadata, so answering with one of its revisions -would leave a connection neither side could use. Serving that era is separate work; today the server only knows not to -mis-negotiate it. - -This table is mirrored by the `provideNegotiationTable()` data provider in -`tests/Unit/Server/Handler/Request/InitializeHandlerTest.php`, which drives its supported-revision rows off the enum so -a newly declared revision is covered automatically. - -### Pinning a revision - -`setProtocolVersion()` pins the handshake to exactly one revision instead of negotiating across the supported set. The -pin wins over the client's request, so a client asking for anything else receives the pinned revision as a -counter-offer and has to decide whether to continue. Leave it unset unless you have a reason to refuse other revisions. - -> [!NOTE] -> On the Streamable HTTP transport, every request after the handshake also carries an `MCP-Protocol-Version` header, -> which is validated separately by `ProtocolVersionMiddleware`. The pin does not reach that check: the transport -> builds the middleware without access to the server configuration, so the header keeps being accepted for every -> revision in `ProtocolVersion::handshakeVersions()`. To narrow it too, construct the middleware yourself with the -> same revision — see [Protocol Version Validation](transports.md#protocol-version-validation). - -## Discovery Configuration - -**Required when using MCP attributes.** If you're using PHP attributes (`#[McpTool]`, `#[McpResource]`, `#[McpResourceTemplate]`, `#[McpPrompt]`) to define your MCP elements, you **MUST** configure discovery to tell the server where to look for these attributes. - -```php -$server = Server::builder() - ->setDiscovery( - basePath: __DIR__, - scanDirs: ['.', 'src', 'lib'], // Where to look for MCP attributes - excludeDirs: ['vendor', 'tests'], // Where NOT to look - cache: $cacheInstance, // Optional: cache discovered elements - namePatterns: ['*.php', '*.inc'], // Optional: list of filename patterns to match - ); -``` - -**Parameters:** -- `$basePath` (string): Base directory for discovery (typically `__DIR__`) -- `$scanDirs` (array): Directories to recursively scan for `#[McpTool]`, `#[McpResource]`, etc. All subdirectories are included. (default: `['.', 'src']`) -- `$excludeDirs` (array): Directory names to exclude **within** the scanned directories during recursive scanning -- `$cache` (CacheInterface|null): Optional PSR-16 cache to store discovered elements for performance -- `$namePatterns` (array): Optional list of patterns (regexp, glob, or string) for file names (default: `['*.php']`) - -**Basic Discovery (scans current directory and `src/`):** -```php -$server = Server::builder() - ->setDiscovery(__DIR__) // Minimal setup - ->build(); -``` - -**Production Setup with Caching:** -```php -use Symfony\Component\Cache\Adapter\FilesystemAdapter; -use Symfony\Component\Cache\Psr16Cache; - -// Cache discovered elements to avoid filesystem scanning on every server start -$cache = new Psr16Cache(new FilesystemAdapter('mcp-discovery')); - -$server = Server::builder() - ->setDiscovery( - basePath: __DIR__, - scanDirs: ['src', 'lib'], // Scan these directories recursively - excludeDirs: ['vendor', 'tests', 'temp'], // Skip these directory names within scanned dirs - cache: $cache // Cache for performance - ) - ->build(); -``` - -**How `excludeDirs` works:** -- If scanning `src/` and there's `src/vendor/`, it will be excluded -- If scanning `lib/` and there's `lib/tests/`, it will be excluded -- But if `vendor/` and `tests/` are at the same level as `src/`, they're not scanned anyway (not in `scanDirs`) - -> **Performance**: Always use a cache in production. The first run scans and caches all discovered MCP elements, making -> subsequent server startups nearly instantaneous. - -## Session Management - -Configure session storage and lifecycle. By default, the SDK uses `InMemorySessionStore`: - -```php -use Mcp\Server\Session\FileSessionStore; -use Mcp\Server\Session\InMemorySessionStore; -use Mcp\Server\Session\Psr16SessionStore; -use Symfony\Component\Cache\Psr16Cache; -use Symfony\Component\Cache\Adapter\RedisAdapter; - -// Override with file-based storage -$server = Server::builder() - ->setSession(new FileSessionStore(__DIR__ . '/sessions')) - ->build(); - -// Override with in-memory storage and custom TTL -$server = Server::builder() - ->setSession(new InMemorySessionStore(3600)) - ->build(); - -// Override with PSR-16 cache-based storage -// Requires psr/simple-cache and symfony/cache (or any other PSR-16 implementation) -// composer require psr/simple-cache symfony/cache -$redisAdapter = new RedisAdapter( - RedisAdapter::createConnection('redis://localhost:6379'), - 'mcp_sessions' -); - -$server = Server::builder() - ->setSession(new Psr16SessionStore( - cache: new Psr16Cache($redisAdapter), - prefix: 'mcp-', - ttl: 3600 - )) - ->build(); -``` - -### Garbage Collection Configuration - -The SDK periodically runs garbage collection to clean up expired sessions, similar to PHP's native -`session.gc_probability` and `session.gc_divisor` settings. The probability that GC runs on any given -request is `gcProbability / gcDivisor`. - -```php -// Default: 1/100 (1% chance per request) -$server = Server::builder() - ->setSession(new FileSessionStore(__DIR__ . '/sessions')) - ->build(); - -// Higher frequency: 1/10 (10% chance per request) -$server = Server::builder() - ->setSession( - new FileSessionStore(__DIR__ . '/sessions'), - gcProbability: 1, - gcDivisor: 10, - ) - ->build(); - -// Run GC on every request -$server = Server::builder() - ->setSession(gcProbability: 1, gcDivisor: 1) - ->build(); - -// Disable GC entirely (e.g. when using an external cleanup process) -$server = Server::builder() - ->setSession(gcProbability: 0) - ->build(); -``` - -**Parameters:** -- `$gcProbability` (int): The numerator of the GC probability fraction (default: `1`). Set to `0` to disable GC. -- `$gcDivisor` (int): The denominator of the GC probability fraction (default: `100`). Must be >= 1. - -> **Note**: When providing a custom `SessionManagerInterface` via the `$sessionManager` parameter, -> the `gcProbability` and `gcDivisor` settings are ignored — you control GC behavior in your own implementation. - -**Available Session Stores:** -- `InMemorySessionStore`: Fast in-memory storage (default) -- `FileSessionStore`: Persistent file-based storage -- `Psr16StoreSession`: PSR-16 compliant cache-based storage - -**Custom Session Stores:** - -Implement `SessionStoreInterface` to create custom session storage: - -```php -use Mcp\Server\Session\SessionStoreInterface; -use Symfony\Component\Uid\Uuid; - -class RedisSessionStore implements SessionStoreInterface -{ - public function __construct(private $redis, private int $ttl = 3600) {} - - public function exists(Uuid $id): bool - { - return $this->redis->exists($id->toRfc4122()); - } - - public function read(Uuid $sessionId): string|false - { - $data = $this->redis->get($sessionId->toRfc4122()); - return $data !== false ? $data : false; - } - - public function write(Uuid $sessionId, string $data): bool - { - return $this->redis->setex($sessionId->toRfc4122(), $this->ttl, $data); - } - - public function destroy(Uuid $sessionId): bool - { - return $this->redis->del($sessionId->toRfc4122()) > 0; - } - - public function gc(): array - { - // Redis handles TTL automatically - return []; - } -} -``` - -## Manual Capability Registration - -Register MCP elements programmatically without using attributes. The handler is the most important parameter and can be any PHP callable. - -### Handler Types - -**Handler** can be any PHP callable: - -1. **Closure**: `function(int $a, int $b): int { return $a + $b; }` -2. **Class and method name pair**: `[ClassName::class, 'methodName']` - the class is instantiated lazily on first call, so it must be constructable through the container (or have a no-arg constructor) -3. **Class instance and method name**: `[$instance, 'methodName']` - the given, already-constructed object is invoked as-is. Use this for handlers the container cannot build, e.g. those with scalar constructor arguments or dependencies wired at runtime -4. **Invokable class name**: `InvokableClass::class` - class must be constructable through the container and have `__invoke` method - -### Manual Tool Registration - -```php -$server = Server::builder() - // Using closure - ->addTool( - handler: function(int $a, int $b): int { return $a + $b; }, - name: 'add_numbers', - description: 'Adds two numbers together' - ) - - // Using class method pair - ->addTool( - handler: [Calculator::class, 'multiply'], - name: 'multiply_numbers' - // name and description are optional - derived from method name and docblock - ) - - // Using instance method - ->addTool( - handler: [$calculatorInstance, 'divide'] - ) - - // Using invokable class - ->addTool( - handler: InvokableCalculator::class - ); -``` - -#### Parameters - -- `handler` (callable|string): The tool handler -- `name` (string|null): Optional tool name -- `title` (string|null): Optional human-readable title for display in UI -- `description` (string|null): Optional tool description -- `annotations` (ToolAnnotations|null): Optional annotations for the tool -- `inputSchema` (array|null): Optional input schema for the tool -- `icons` (Icon[]|null): Optional array of icons for the tool -- `meta` (array|null): Optional metadata for the tool - -### Manual Resource Registration - -Register static resources: - -```php -$server = Server::builder() - ->addResource( - handler: [Config::class, 'getSettings'], - uri: 'config://app/settings', - name: 'app_config', - description: 'Application configuration', - mimeType: 'application/json' - ); -``` - -#### Parameters - -- `handler` (callable|string): The resource handler -- `uri` (string): The resource URI -- `name` (string|null): Optional resource name -- `description` (string|null): Optional resource description -- `mimeType` (string|null): Optional MIME type of the resource -- `size` (int|null): Optional size of the resource in bytes -- `annotations` (Annotations|null): Optional annotations for the resource -- `icons` (Icon[]|null): Optional array of icons for the resource -- `meta` (array|null): Optional metadata for the resource - -### Manual Resource Template Registration - -Register dynamic resources with URI templates: - -```php -$server = Server::builder() - ->addResourceTemplate( - handler: [UserService::class, 'getUserProfile'], - uriTemplate: 'user://{userId}/profile', - name: 'user_profile', - description: 'User profile by ID', - mimeType: 'application/json' - ); -``` - -#### Parameters - -- `handler` (callable|string): The resource template handler -- `uriTemplate` (string): The resource URI template -- `name` (string|null): Optional resource template name -- `description` (string|null): Optional resource template description -- `mimeType` (string|null): Optional MIME type of the resource -- `annotations` (Annotations|null): Optional annotations for the resource template - -### Manual Prompt Registration - -Register prompt generators: - -```php -$server = Server::builder() - ->addPrompt( - handler: [PromptService::class, 'generatePrompt'], - name: 'custom_prompt', - description: 'A custom prompt generator' - ); -``` - -#### Parameters - -- `handler` (callable|string): The prompt handler -- `name` (string|null): Optional prompt name -- `title` (string|null): Optional human-readable title for display in UI -- `description` (string|null): Optional prompt description -- `icons` (Icon[]|null): Optional array of icons for the prompt - -**Note:** `name` and `description` are optional for all manual registrations. If not provided, they will be derived from -the handler's method name and docblock. - -For more details on MCP elements, handlers, and attribute-based discovery, see [MCP Elements](mcp-elements.md). - -### Explicit element registration - -When an element's name, schema, or description is only known at runtime, pair an `Mcp\Schema\*` value object with one of -the four handler interfaces below and register it through `Builder::add()`. - -| Element kind | Handler interface | -|-------------------|-------------------------------------------------------| -| Tool | `Mcp\Server\Handler\ToolHandlerInterface` | -| Resource | `Mcp\Server\Handler\ResourceHandlerInterface` | -| Resource template | `Mcp\Server\Handler\ResourceTemplateHandlerInterface` | -| Prompt | `Mcp\Server\Handler\PromptHandlerInterface` | - -Each handler interface declares a single execution method. Tool and prompt handlers receive an arguments map and a -`ClientGateway`. Resource handlers receive the requested URI; resource template handlers additionally receive the parsed -template variables. - -```php -use Mcp\Schema\Tool; -use Mcp\Server; -use Mcp\Server\ClientGateway; -use Mcp\Server\Handler\ToolHandlerInterface; - -final class WeatherHandler implements ToolHandlerInterface -{ - public function execute(array $arguments, ClientGateway $gateway): mixed - { - return ['temperature' => 21, 'unit' => 'C']; - } -} - -$tool = new Tool( - name: 'get_weather', - title: null, - inputSchema: [ - 'type' => 'object', - 'properties' => ['city' => ['type' => 'string']], - 'required' => ['city'], - ], - description: 'Returns the current weather for a city.', - annotations: null, -); - -$server = Server::builder() - ->add($tool, new WeatherHandler()) - ->build(); -``` - -`Builder::add()` validates the pairing at registration time. Pairing a `Tool` definition with, for example, a -`PromptHandlerInterface` raises `Mcp\Exception\InvalidArgumentException`. The schema value object validates its own -inputs (name pattern, schema shape, etc.), so passing an incomplete definition fails before `add()` returns. - -Use `add()` when the metadata cannot be inferred from a handler class via reflection. For statically-known elements, -prefer `addTool/addResource/addResourceTemplate/addPrompt`, which can derive metadata from the handler's signature and -docblock. - -## Service Dependencies - -### Container - -The container is used to resolve handlers and their dependencies when handlers inject dependencies in their constructors. -The SDK includes a basic container with simple auto-wiring capabilities. - -```php -use Mcp\Capability\Registry\Container; - -// Use the default basic container -$container = new Container(); -$container->set(DatabaseService::class, new DatabaseService($pdo)); -$container->set(\PDO::class, $pdo); - -$server = Server::builder() - ->setContainer($container) - ->build(); -``` - -**Basic Container Features:** -- Supports constructor auto-wiring for classes with parameterless constructors -- Resolves dependencies where all parameters are type-hinted classes/interfaces known to the container -- Supports parameters with default values -- Does NOT support scalar/built-in type injection without defaults -- Detects circular dependencies - -You can also use any PSR-11 compatible container (Symfony DI, PHP-DI, Laravel Container, etc.). - -### Logger - -Provide a PSR-3 logger instance for internal server logging (request/response processing, errors, session management, transport events): - -```php -use Monolog\Logger; -use Monolog\Handler\StreamHandler; - -$logger = new Logger('mcp-server'); -$logger->pushHandler(new StreamHandler('mcp.log', Logger::INFO)); - -$server = Server::builder() - ->setLogger($logger); -``` - -### Event Dispatcher - -Configure event dispatching: - -```php -$server = Server::builder() - ->setEventDispatcher($eventDispatcher); -``` - -## Custom Message Handlers - -**Low-level escape hatch.** Custom message handlers run before the SDK's built-in handlers and give you total control over -individual JSON-RPC messages. They do not receive the builder's registry, container, or discovery output unless you pass -those dependencies in yourself. - -> **Warning**: Custom message handlers bypass discovery, manual capability registration, and container lookups (unless -> you explicitly pass them). Tools, resources, and prompts you register elsewhere will not show up unless your handler -> loads and executes them manually. Reach for this API only when you need that level of control and are comfortable -> taking on the additional plumbing. - -### Request Handlers - -Handle JSON-RPC requests (messages with an `id` that expect a response). Request handlers **must** return either a -`Response` or an `Error` object. - -Attach request handlers with `addRequestHandler()` (single) or `addRequestHandlers()` (multiple). You can call these -methods as many times as needed; each call prepends the handlers so they execute before the defaults: - -```php -$server = Server::builder() - ->addRequestHandler(new CustomListToolsHandler()) - ->addRequestHandlers([ - new CustomCallToolHandler(), - new CustomGetPromptHandler(), - ]) - ->build(); -``` - -Request handlers implement `RequestHandlerInterface`: - -```php -use Mcp\Schema\JsonRpc\Error; -use Mcp\Schema\JsonRpc\Request; -use Mcp\Schema\JsonRpc\Response; -use Mcp\Server\Handler\Request\RequestHandlerInterface; -use Mcp\Server\Session\SessionInterface; - -interface RequestHandlerInterface -{ - public function supports(Request $request): bool; - - public function handle(Request $request, SessionInterface $session): Response|Error; -} -``` - -- `supports()` decides if the handler should process the incoming request -- `handle()` **must** return a `Response` (on success) or an `Error` (on failure) - -### Notification Handlers - -Handle JSON-RPC notifications (messages without an `id` that don't expect a response). Notification handlers **do not** -return anything - they perform side effects only. - -Attach notification handlers with `addNotificationHandler()` (single) or `addNotificationHandlers()` (multiple): - -```php -$server = Server::builder() - ->addNotificationHandler(new LoggingNotificationHandler()) - ->addNotificationHandlers([ - new InitializedNotificationHandler(), - new ProgressNotificationHandler(), - ]) - ->build(); -``` - -Notification handlers implement `NotificationHandlerInterface`: - -```php -use Mcp\Schema\JsonRpc\Notification; -use Mcp\Server\Handler\Notification\NotificationHandlerInterface; -use Mcp\Server\Session\SessionInterface; - -interface NotificationHandlerInterface -{ - public function supports(Notification $notification): bool; - - public function handle(Notification $notification, SessionInterface $session): void; -} -``` - -- `supports()` decides if the handler should process the incoming notification -- `handle()` performs side effects but **does not** return a value (notifications have no response) - -### Key Differences - -| Handler Type | Interface | Returns | Use Case | -|-------------|-----------|---------|----------| -| Request Handler | `RequestHandlerInterface` | `Response\|Error` | Handle requests that need responses (e.g., `tools/list`, `tools/call`) | -| Notification Handler | `NotificationHandlerInterface` | `void` | Handle fire-and-forget notifications (e.g., `notifications/initialized`, `notifications/progress`) | - -### Example - -Check out `examples/custom-method-handlers/server.php` for a complete example showing how to implement -custom `tools/list` and `tools/call` request handlers independently of the registry. - -## Complete Example - -Here's a comprehensive example showing all major configuration options: - -```php -use Mcp\Server; -use Mcp\Server\Session\FileSessionStore; -use Mcp\Capability\Registry\Container; -use Symfony\Component\Cache\Adapter\FilesystemAdapter; -use Symfony\Component\Cache\Psr16Cache; -use Monolog\Logger; -use Monolog\Handler\StreamHandler; - -// Setup dependencies -$logger = new Logger('mcp-server'); -$logger->pushHandler(new StreamHandler('mcp.log', Logger::INFO)); - -$cache = new Psr16Cache(new FilesystemAdapter('mcp-discovery')); -$sessionStore = new FileSessionStore(__DIR__ . '/sessions'); - -// Setup container with dependencies -$container = new Container(); -$container->set(\PDO::class, new \PDO('sqlite::memory:')); -$container->set(DatabaseService::class, new DatabaseService($container->get(\PDO::class))); - -// Build server -$server = Server::builder() - // Server identity - ->setServerInfo('Advanced Calculator', '2.1.0') - - // Performance and behavior - ->setPaginationLimit(100) - ->setInstructions('Use calculate tool for math operations. Check config resource for current settings.') - - // Discovery with caching - ->setDiscovery(__DIR__, ['src'], ['vendor', 'tests'], $cache) - - // Session management - ->setSession($sessionStore) - - // Services - ->setLogger($logger) - ->setContainer($container) - - // Manual capability registration - ->addTool([Calculator::class, 'advancedCalculation'], 'advanced_calc') - ->addResource([Config::class, 'getSettings'], 'config://app/settings', 'app_settings') - - // Build the server - ->build(); -``` - -## Method Reference - -| Method | Parameters | Description | -|--------|------------|-------------| -| `setServerInfo()` | name, version, description? | Set server identity | -| `setPaginationLimit()` | limit | Set max items per page | -| `setInstructions()` | instructions | Set usage instructions | -| `setProtocolVersion()` | protocolVersion | Pin the handshake to one protocol revision | -| `setDiscovery()` | basePath, scanDirs?, excludeDirs?, cache? | Configure attribute discovery | -| `setSession()` | sessionStore?, sessionManager?, gcProbability?, gcDivisor? | Configure session management | -| `setLogger()` | logger | Set PSR-3 logger | -| `setContainer()` | container | Set PSR-11 container | -| `setEventDispatcher()` | dispatcher | Set PSR-14 event dispatcher | -| `addRequestHandler()` | handler | Prepend a single custom request handler | -| `addRequestHandlers()` | handlers | Prepend multiple custom request handlers | -| `addNotificationHandler()` | handler | Prepend a single custom notification handler | -| `addNotificationHandlers()` | handlers | Prepend multiple custom notification handlers | -| `addTool()` | handler, name?, title?, description?, annotations?, inputSchema?, ... | Register tool | -| `addResource()` | handler, uri, name?, title?, description?, mimeType?, size?, annotations?, icons?, meta? | Register resource | -| `addResourceTemplate()` | handler, uriTemplate, name?, title?, description?, mimeType?, annotations?, meta? | Register resource template | -| `addPrompt()` | handler, name?, title?, description?, icons?, meta? | Register prompt | -| `add()` | definition, handler | Register an element from a schema VO + handler pair | -| `build()` | - | Create the server instance | diff --git a/docs/servers/completions.md b/docs/servers/completions.md new file mode 100644 index 00000000..c4226205 --- /dev/null +++ b/docs/servers/completions.md @@ -0,0 +1,98 @@ +# Completion Providers + +Completion providers help MCP clients offer auto-completion suggestions for Resource Templates and Prompts. Unlike Tools and static Resources (which can be listed via `tools/list` and `resources/list`), Resource Templates and Prompts have dynamic parameters that benefit from completion hints. + +## Completion Provider Types + +### 1. Value Lists + +Provide a static list of possible values: + +```php +use Mcp\Capability\Attribute\CompletionProvider; + +#[McpPrompt] +public function generateContent( + #[CompletionProvider(values: ['blog', 'article', 'tutorial', 'guide'])] + string $contentType, + + #[CompletionProvider(values: ['beginner', 'intermediate', 'advanced'])] + string $difficulty +): array +{ + return [ + ['role' => 'user', 'content' => "Create a {$difficulty} level {$contentType}"] + ]; +} +``` + +### 2. Enum Classes + +Use enum values for completion: + +```php +enum Priority: string +{ + case LOW = 'low'; + case MEDIUM = 'medium'; + case HIGH = 'high'; +} + +enum Status // Unit enum +{ + case DRAFT; + case PUBLISHED; + case ARCHIVED; +} + +#[McpResourceTemplate(uriTemplate: 'tasks://{priority}/{status}')] +public function getTask( + #[CompletionProvider(enum: Priority::class)] // Uses backing values + string $priority, + + #[CompletionProvider(enum: Status::class)] // Uses case names + string $status +): array +{ + // Implementation +} +``` + +### 3. Custom Provider Classes + +For dynamic completion logic: + +```php +use Mcp\Capability\Completion\ProviderInterface; + +class UserIdCompletionProvider implements ProviderInterface +{ + public function __construct(private DatabaseService $db) {} + + public function getCompletions(string $currentValue): array + { + // Return dynamic completions based on current input + return $this->db->searchUserIds($currentValue); + } +} + +#[McpResourceTemplate(uriTemplate: 'user://{userId}/profile')] +public function getUserProfile( + #[CompletionProvider(provider: UserIdCompletionProvider::class)] + string $userId +): array +{ + // Implementation +} +``` + +**Provider Resolution:** +- **Class strings** (`Provider::class`) → Resolved from PSR-11 container +- **Instances** (`new Provider()`) → Used directly +- **Values** (`['a', 'b']`) → Wrapped in `ListCompletionProvider` +- **Enums** (`MyEnum::class`) → Wrapped in `EnumCompletionProvider` + +> **Important** +> +> Completion providers only offer **suggestions** to users. Users can still input any value, so **always validate +> parameters** in your handlers. Providers don't enforce validation - they're purely for UX improvement. diff --git a/docs/servers/index.md b/docs/servers/index.md new file mode 100644 index 00000000..616acacf --- /dev/null +++ b/docs/servers/index.md @@ -0,0 +1,30 @@ +# Servers + +An MCP server exposes four kinds of elements to a connected client. They differ by who +decides to use them: + +* A **[tool](tools.md)** is an action the *model* picks and calls. This is the page most + people want first. +* A **[resource](resources.md)** is read-only data the *application* chooses to read, + addressed by a fixed URI. **[Resource templates](resource-templates.md)** are the same + thing with variables in the URI, for data that is generated per request. +* A **[prompt](prompts.md)** is a message template a *person* invokes by name, from a + menu or a slash command. + +Around those, the rest of what a server declares: + +* **[Completions](completions.md)** is server-side autocomplete for prompt and + resource-template arguments. +* **[Schema generation](schemas.md)** explains how your PHP types and docblocks become + the JSON Schema a model sees, and how to override it where the types are not enough. +* **[Registering elements](registration.md)** covers the three ways an element reaches + the registry: attribute discovery, explicit registration, or both at once. + +Every page here stands on its own; jump straight to the one you need. If you have not +built a server yet, start with **[First server](../get-started/first-server.md)** +instead. + +What happens *inside* the functions you register — logging, progress, asking the client +for an LLM completion — is the next section, +**[Inside your handler](../handlers/index.md)**. Getting the server in front of a client +is **[Running your server](../run/index.md)**. diff --git a/docs/servers/prompts.md b/docs/servers/prompts.md new file mode 100644 index 00000000..e5fb4a61 --- /dev/null +++ b/docs/servers/prompts.md @@ -0,0 +1,130 @@ +# Prompts + +Prompts generate templates for AI interactions. + +```php +use Mcp\Capability\Attribute\McpPrompt; + +class PromptGenerator +{ + /** + * Generates a code review request prompt. + */ + #[McpPrompt(name: 'code_review')] + public function reviewCode(string $language, string $code, string $focus = 'general'): array + { + return [ + ['role' => 'assistant', 'content' => 'You are an expert code reviewer.'], + ['role' => 'user', 'content' => "Review this {$language} code focusing on {$focus}:\n\n```{$language}\n{$code}\n```"] + ]; + } +} +``` + +## Parameters + +- **`name`** (optional): Prompt identifier. Defaults to method name if not provided. +- **`title`** (optional): Human-readable display title shown in client UI. Distinct from `name`. +- **`description`** (optional): Prompt description. Defaults to docblock summary if not provided. +- **`icons`** (optional): Array of `Icon` objects for visual representation. +- **`meta`** (optional): Arbitrary key-value pairs for custom metadata. + +## Prompt Return Values + +Prompt handlers must return an array of message structures that are automatically formatted into MCP prompt messages. + +### Supported Return Formats + +```php +// Array of message objects with role and content +public function basicPrompt(): array +{ + return [ + ['role' => 'assistant', 'content' => 'You are a helpful assistant'], + ['role' => 'user', 'content' => 'Hello, how are you?'] + ]; +} + +// Single message (automatically wrapped in array) +public function singleMessage(): array +{ + return [ + ['role' => 'user', 'content' => 'Write a poem about PHP'] + ]; +} + +// Associative array with user/assistant keys +public function userAssistantFormat(): array +{ + return [ + 'user' => 'Explain how arrays work in PHP', + 'assistant' => 'Arrays in PHP are ordered maps...' + ]; +} + +// Mixed content types in messages +use Mcp\Schema\Content\{TextContent, ImageContent}; + +public function mixedContent(): array +{ + return [ + [ + 'role' => 'user', + 'content' => [ + new TextContent('Analyze this image:'), + new ImageContent(data: $imageData, mimeType: 'image/png') + ] + ] + ]; +} + +// Using explicit PromptMessage objects +use Mcp\Schema\Content\PromptMessage; +use Mcp\Schema\Enum\Role; + +public function explicitMessages(): array +{ + return [ + new PromptMessage(Role::Assistant, new TextContent('System instructions')), + new PromptMessage(Role::User, new TextContent('User question')) + ]; +} +``` + +The SDK automatically validates that all messages have valid roles and converts the result into the appropriate MCP prompt message format. + +### Valid Message Roles + +- **`user`**: User input or questions +- **`assistant`**: Assistant responses, including system-style instructions + +Those two are the only valid roles — MCP has no `system` role, and any other value +makes the prompt handler throw. + +### Error Handling + +Prompt handlers can throw any exception, but the type determines how it's handled: +- **`PromptGetException`**: Converted to JSON-RPC error response with the actual exception message +- **Any other exception**: Converted to JSON-RPC error response, but with a generic error message + +```php +use Mcp\Exception\PromptGetException; + +#[McpPrompt] +public function generatePrompt(string $topic, string $style): array +{ + $validStyles = ['casual', 'formal', 'technical']; + + if (!in_array($style, $validStyles)) { + throw new PromptGetException( + "Invalid style '{$style}'. Must be one of: " . implode(', ', $validStyles) + ); + } + + return [ + ['role' => 'user', 'content' => "Write about {$topic} in a {$style} style"] + ]; +} +``` + +**Recommendation**: Use `PromptGetException` when you want to communicate specific errors to clients. Any other exception will still be converted to JSON-RPC compliant errors but with generic error messages. diff --git a/docs/servers/registration.md b/docs/servers/registration.md new file mode 100644 index 00000000..9a5c0b00 --- /dev/null +++ b/docs/servers/registration.md @@ -0,0 +1,242 @@ +# Registering elements + +Every tool, resource, resource template, and prompt has to reach the server's +registry somehow. There are three ways to get it there, and they mix freely. + +## Attribute-Based Discovery + +**Advantages:** +- Declarative and readable +- Automatic parameter inference +- DocBlock integration +- Type-safe by default +- Caching support + +**Example:** +```php +$server = Server::builder() + ->setDiscovery(__DIR__, ['.']) // Automatic discovery + ->build(); +``` + +## Manual Registration + +Register MCP elements programmatically without using attributes. The handler is the most important parameter and can be +any PHP callable. + +**Advantages:** +- Fine-grained control +- Runtime configuration +- Conditional registration +- External handler support + +**Example:** +```php +$server = Server::builder() + ->addTool([Calculator::class, 'add'], 'add_numbers') + ->addResource([Config::class, 'get'], 'config://app') + ->addPrompt([Prompts::class, 'email'], 'write_email') + ->build(); +``` + + +### Handler Types + +**Handler** can be any PHP callable: + +1. **Closure**: `function(int $a, int $b): int { return $a + $b; }` +2. **Class and method name pair**: `[ClassName::class, 'methodName']` - the class is instantiated lazily on first call, so it must be constructable through the container (or have a no-arg constructor) +3. **Class instance and method name**: `[$instance, 'methodName']` - the given, already-constructed object is invoked as-is. Use this for handlers the container cannot build, e.g. those with scalar constructor arguments or dependencies wired at runtime +4. **Invokable class name**: `InvokableClass::class` - class must be constructable through the container and have `__invoke` method + +### Manual Tool Registration + +```php +$server = Server::builder() + // Using closure + ->addTool( + handler: function(int $a, int $b): int { return $a + $b; }, + name: 'add_numbers', + description: 'Adds two numbers together' + ) + + // Using class method pair + ->addTool( + handler: [Calculator::class, 'multiply'], + name: 'multiply_numbers' + // name and description are optional - derived from method name and docblock + ) + + // Using instance method + ->addTool( + handler: [$calculatorInstance, 'divide'] + ) + + // Using invokable class + ->addTool( + handler: InvokableCalculator::class + ); +``` + +#### Parameters + +- `handler` (callable|string): The tool handler +- `name` (string|null): Optional tool name +- `title` (string|null): Optional human-readable title for display in UI +- `description` (string|null): Optional tool description +- `annotations` (ToolAnnotations|null): Optional annotations for the tool +- `inputSchema` (array|null): Optional input schema for the tool +- `icons` (Icon[]|null): Optional array of icons for the tool +- `meta` (array|null): Optional metadata for the tool + +### Manual Resource Registration + +Register static resources: + +```php +$server = Server::builder() + ->addResource( + handler: [Config::class, 'getSettings'], + uri: 'config://app/settings', + name: 'app_config', + description: 'Application configuration', + mimeType: 'application/json' + ); +``` + +#### Parameters + +- `handler` (callable|string): The resource handler +- `uri` (string): The resource URI +- `name` (string|null): Optional resource name +- `description` (string|null): Optional resource description +- `mimeType` (string|null): Optional MIME type of the resource +- `size` (int|null): Optional size of the resource in bytes +- `annotations` (Annotations|null): Optional annotations for the resource +- `icons` (Icon[]|null): Optional array of icons for the resource +- `meta` (array|null): Optional metadata for the resource + +### Manual Resource Template Registration + +Register dynamic resources with URI templates: + +```php +$server = Server::builder() + ->addResourceTemplate( + handler: [UserService::class, 'getUserProfile'], + uriTemplate: 'user://{userId}/profile', + name: 'user_profile', + description: 'User profile by ID', + mimeType: 'application/json' + ); +``` + +#### Parameters + +- `handler` (callable|string): The resource template handler +- `uriTemplate` (string): The resource URI template +- `name` (string|null): Optional resource template name +- `description` (string|null): Optional resource template description +- `mimeType` (string|null): Optional MIME type of the resource +- `annotations` (Annotations|null): Optional annotations for the resource template + +### Manual Prompt Registration + +Register prompt generators: + +```php +$server = Server::builder() + ->addPrompt( + handler: [PromptService::class, 'generatePrompt'], + name: 'custom_prompt', + description: 'A custom prompt generator' + ); +``` + +#### Parameters + +- `handler` (callable|string): The prompt handler +- `name` (string|null): Optional prompt name +- `title` (string|null): Optional human-readable title for display in UI +- `description` (string|null): Optional prompt description +- `icons` (Icon[]|null): Optional array of icons for the prompt + +**Note:** `name` and `description` are optional when the handler is a method or an invokable class — they are then +derived from the method name and its docblock. A **closure** handler has neither, so it gets a generated name +(`closure_tool_`) and no description; name your closures explicitly. + +For more details on the elements themselves, see [Tools](tools.md), [Resources](resources.md), [Resource templates](resource-templates.md), and [Prompts](prompts.md). + +### Explicit element registration + +When an element's name, schema, or description is only known at runtime, pair an `Mcp\Schema\*` value object with one of +the four handler interfaces below and register it through `Builder::add()`. + +| Element kind | Handler interface | +|-------------------|-------------------------------------------------------| +| Tool | `Mcp\Server\Handler\ToolHandlerInterface` | +| Resource | `Mcp\Server\Handler\ResourceHandlerInterface` | +| Resource template | `Mcp\Server\Handler\ResourceTemplateHandlerInterface` | +| Prompt | `Mcp\Server\Handler\PromptHandlerInterface` | + +Each handler interface declares a single execution method. Tool and prompt handlers receive an arguments map and a +`ClientGateway`. Resource handlers receive the requested URI; resource template handlers additionally receive the parsed +template variables. + +```php +use Mcp\Schema\Tool; +use Mcp\Server; +use Mcp\Server\ClientGateway; +use Mcp\Server\Handler\ToolHandlerInterface; + +final class WeatherHandler implements ToolHandlerInterface +{ + public function execute(array $arguments, ClientGateway $gateway): mixed + { + return ['temperature' => 21, 'unit' => 'C']; + } +} + +$tool = new Tool( + name: 'get_weather', + title: null, + inputSchema: [ + 'type' => 'object', + 'properties' => ['city' => ['type' => 'string']], + 'required' => ['city'], + ], + description: 'Returns the current weather for a city.', + annotations: null, +); + +$server = Server::builder() + ->add($tool, new WeatherHandler()) + ->build(); +``` + +`Builder::add()` validates the pairing at registration time. Pairing a `Tool` definition with, for example, a +`PromptHandlerInterface` raises `Mcp\Exception\InvalidArgumentException`. The schema value objects validate some of +their own input as well — `Tool` requires an object-typed input schema, `ResourceDefinition` and `ResourceTemplate` +check the name pattern and URI — but an invalid tool or prompt *name* is not rejected, it is only logged as a warning +when the element is registered. + +Use `add()` when the metadata cannot be inferred from a handler class via reflection. For statically-known elements, +prefer `addTool/addResource/addResourceTemplate/addPrompt`, which can derive metadata from the handler's signature and +docblock. + +## Hybrid Approach + +Combine both methods for maximum flexibility: + +```php +$server = Server::builder() + ->setDiscovery(__DIR__, ['.']) // Discover most capabilities + ->addTool([ExternalService::class, 'process'], 'external') // Add specific ones + ->build(); +``` + +Manual registrations always take precedence over discovered elements with the same identifier — same `name` for tools +and prompts, same `uri` for resources, same `uriTemplate` for resource templates. + +For runtime, config-driven elements whose shape is not known at compile time, see +[Explicit element registration](#explicit-element-registration). diff --git a/docs/servers/resource-templates.md b/docs/servers/resource-templates.md new file mode 100644 index 00000000..ab6867c4 --- /dev/null +++ b/docs/servers/resource-templates.md @@ -0,0 +1,48 @@ +# Resource Templates + +Resource templates are **dynamic resources** that use parameterized URIs with variables. They follow all the same rules +as static resources (URI schemas, return values, MIME types, etc.) but accept `{variable}` placeholders in the URI. + +Only simple [RFC 6570](https://datatracker.ietf.org/doc/html/rfc6570) variable expansion +is supported — `{var}`, one path segment each. Operators such as `{+var}`, `{#var}`, +`{/path}`, `{?query}` and explode (`{list*}`) are not parsed, and a variable's value +cannot contain `/`. + +```php +use Mcp\Capability\Attribute\McpResourceTemplate; + +class UserProvider +{ + /** + * Retrieves user profile information by ID. + */ + #[McpResourceTemplate( + uriTemplate: 'user://{userId}/profile/{section}', + name: 'user_profile', + description: 'User profile data by section', + mimeType: 'application/json' + )] + public function getUserProfile(string $userId, string $section): array + { + return $this->users[$userId][$section] ?? throw new \InvalidArgumentException("Profile section not found"); + } +} +``` + +## Parameters + +- **`uriTemplate`** (required): URI with `{variables}`. Must start with a scheme (`file://`, `user://`, …) and contain at least one variable. +- **`name`** (optional): Short resource template identifier. Defaults to method name if not provided. +- **`title`** (optional): Human-readable display title shown in client UI. Distinct from `name`. +- **`description`** (optional): Template description. Defaults to docblock summary if not provided. +- **`mimeType`** (optional): MIME type of the resource content. +- **`annotations`** (optional): Additional metadata. + +## Variable Rules + +1. **Variable names must match exactly** between URI template and method parameters — + they are bound by name, so the parameter order is free +2. **All variables are required** - no optional parameters supported +3. **Type hints work normally** - parameters can be typed (string, int, etc.) + +**Example mapping**: `user://123/profile/settings` → `getUserProfile("123", "settings")` diff --git a/docs/servers/resources.md b/docs/servers/resources.md new file mode 100644 index 00000000..807a19cd --- /dev/null +++ b/docs/servers/resources.md @@ -0,0 +1,151 @@ +# Resources + +Resources provide access to static data that clients can read. + +```php +use Mcp\Capability\Attribute\McpResource; + +class ConfigProvider +{ + /** + * Provides the current application configuration. + */ + #[McpResource(uri: 'config://app/settings', name: 'app_settings')] + public function getSettings(): array + { + return [ + 'version' => '1.0.0', + 'debug' => false, + 'features' => ['auth', 'logging'] + ]; + } +} +``` + +## Parameters + +- **`uri`** (required): Unique resource identifier. Must comply with [RFC 3986](https://datatracker.ietf.org/doc/html/rfc3986). +- **`name`** (optional): Short resource identifier. Defaults to method name if not provided. +- **`title`** (optional): Human-readable display title shown in client UI. Distinct from `name`. +- **`description`** (optional): Resource description. Defaults to docblock summary if not provided. +- **`mimeType`** (optional): MIME type of the resource content. +- **`size`** (optional): Size in bytes if known. +- **`annotations`** (optional): Additional metadata. +- **`icons`** (optional): Array of `Icon` objects for visual representation. +- **`meta`** (optional): Arbitrary key-value pairs for custom metadata. + +**Standard Protocol URI Schemes**: `https://` (web resources), `file://` (filesystem), `git://` (version control). +**Custom schemes**: `config://`, `data://`, `db://`, `api://` or any RFC 3986 compliant scheme. + +## Resource Return Values + +Resource handlers can return various data types that are automatically formatted into appropriate MCP resource content types. + +### Supported Return Types + +```php +// String content - converted to text resource +public function getTextFile(): string +{ + return "File content here"; +} + +// Array content - converted to JSON +public function getConfig(): array +{ + return ['debug' => true, 'version' => '1.0']; +} + +// Stream resource - read and converted to blob. +// `resource` is not a PHP type declaration, so the return type is left off. +/** @return resource */ +public function getImageStream() +{ + return fopen('image.png', 'r'); +} + +// SplFileInfo - file content with MIME type detection +public function getFileInfo(): \SplFileInfo +{ + return new \SplFileInfo('document.pdf'); +} +``` + +**Explicit resource content types** + +```php +use Mcp\Schema\Content\{TextResourceContents, BlobResourceContents}; + +public function getExplicitText(): TextResourceContents +{ + return new TextResourceContents( + uri: 'config://app/settings', + mimeType: 'application/json', + text: json_encode(['setting' => 'value']) + ); +} + +public function getExplicitBlob(): BlobResourceContents +{ + return new BlobResourceContents( + uri: 'file://image.png', + mimeType: 'image/png', + blob: base64_encode(file_get_contents('image.png')) + ); +} +``` + +**Special Array Formats** + +```php +// Array with 'text' key - used as text content +public function getTextArray(): array +{ + return ['text' => 'Content here', 'mimeType' => 'text/plain']; +} + +// Array with 'blob' key - used as blob content +public function getBlobArray(): array +{ + return ['blob' => base64_encode($data), 'mimeType' => 'image/png']; +} + +// Multiple resource contents +public function getMultipleResources(): array +{ + return [ + new TextResourceContents('file://readme.txt', 'text/plain', 'README content'), + new TextResourceContents('file://config.json', 'application/json', '{"key": "value"}') + ]; +} +``` + +### Error Handling + +Resource handlers can throw any exception, but the type determines how it's handled: + +- **`ResourceReadException`**: Converted to JSON-RPC error response with the actual exception message +- **Any other exception**: Converted to JSON-RPC error response, but with a generic error message + +```php +use Mcp\Exception\ResourceReadException; + +// A URI with variables is a resource *template*; `#[McpResource]` registers a +// fixed URI and would never receive `$path`. Note a variable matches a single +// segment, so `$path` here cannot contain `/`. +#[McpResourceTemplate(uriTemplate: 'file://{path}')] +public function getFile(string $path): string +{ + if (!file_exists($path)) { + throw new ResourceReadException("File not found: {$path}"); + } + + if (!is_readable($path)) { + throw new ResourceReadException("File not readable: {$path}"); + } + + return file_get_contents($path); +} +``` + +**Recommendation**: Use `ResourceReadException` when you want to communicate specific errors to clients. Any other exception will still be converted to JSON-RPC compliant errors but with generic error messages. diff --git a/docs/servers/schemas.md b/docs/servers/schemas.md new file mode 100644 index 00000000..d6d11d7f --- /dev/null +++ b/docs/servers/schemas.md @@ -0,0 +1,111 @@ +# Schema Generation and Validation + +The SDK automatically generates JSON schemas for **tool parameters** using a sophisticated priority system. Schema +generation applies to both attribute-discovered and manually registered tools. + +## Schema Generation Priority + +The server follows this order of precedence: + +1. **`#[Schema]` attribute with `definition`** - Complete schema override (highest priority) +2. **Parameter-level `#[Schema]` attribute** - Parameter-specific enhancements +3. **Method-level `#[Schema]` attribute** - Method-wide configuration +4. **PHP type hints + docblocks** - Automatic inference (lowest priority) + +## Automatic Schema from PHP Types + +```php +#[McpTool] +public function processUser( + string $email, // Required string + int $age, // Required integer + ?string $name = null, // Optional string + bool $active = true // Boolean with default +): array +{ + // Schema auto-generated from method signature +} +``` + +## Parameter-Level Schema Enhancement + +Add validation rules to specific parameters: + +```php +use Mcp\Capability\Attribute\Schema; + +#[McpTool] +public function validateUser( + #[Schema(format: 'email')] + string $email, + + #[Schema(minimum: 18, maximum: 120)] + int $age, + + #[Schema( + pattern: '^[A-Z][a-z]+$', + description: 'Capitalized first name' + )] + string $firstName +): bool +{ + // PHP types provide base validation + // Schema attributes add constraints +} +``` + +## Method-Level Schema + +Add validation for complex object structures: + +```php +#[McpTool] +#[Schema( + properties: [ + 'userData' => [ + 'type' => 'object', + 'properties' => [ + 'name' => ['type' => 'string', 'minLength' => 2], + 'email' => ['type' => 'string', 'format' => 'email'], + 'age' => ['type' => 'integer', 'minimum' => 18] + ], + 'required' => ['name', 'email'] + ] + ], + required: ['userData'] +)] +public function createUser(array $userData): array +{ + // Method-level schema adds object structure validation + // PHP array type provides base type +} +``` + +## Complete Schema Override + +**Use sparingly** - bypasses all automatic inference: + +```php +#[McpTool] +#[Schema(definition: [ + 'type' => 'object', + 'properties' => [ + 'endpoint' => ['type' => 'string', 'format' => 'uri'], + 'method' => ['type' => 'string', 'enum' => ['GET', 'POST', 'PUT', 'DELETE']], + 'headers' => [ + 'type' => 'object', + 'patternProperties' => [ + '^[A-Za-z0-9-]+$' => ['type' => 'string'] + ] + ] + ], + 'required' => ['endpoint', 'method'] +])] +public function makeApiRequest(string $endpoint, string $method, array $headers): array +{ + // Complete definition override - PHP types ignored +} +``` + +**Warning:** Only use complete schema override if you're well-versed with JSON Schema specification and have complex +validation requirements that cannot be achieved through the priority system. diff --git a/docs/servers/tools.md b/docs/servers/tools.md new file mode 100644 index 00000000..c63a8ac3 --- /dev/null +++ b/docs/servers/tools.md @@ -0,0 +1,148 @@ +# Tools + +Tools are callable functions that perform actions and return results. + +```php +use Mcp\Capability\Attribute\McpTool; + +class Calculator +{ + /** + * Performs arithmetic operations with validation. + */ + #[McpTool(name: 'calculate')] + public function performCalculation(float $a, float $b, string $operation): float + { + return match($operation) { + 'add' => $a + $b, + 'subtract' => $a - $b, + 'multiply' => $a * $b, + 'divide' => $b != 0 ? $a / $b : throw new \InvalidArgumentException('Division by zero'), + default => throw new \InvalidArgumentException('Invalid operation') + }; + } +} +``` + +## Parameters + +- **`name`** (optional): Tool identifier. Defaults to method name if not provided. +- **`title`** (optional): Human-readable display title shown in client UI. Distinct from `name`. +- **`description`** (optional): Tool description. Falls back to the docblock (summary plus long description); stays unset if there is no docblock. +- **`annotations`** (optional): `ToolAnnotations` object for additional metadata. +- **`icons`** (optional): Array of `Icon` objects for visual representation. +- **`meta`** (optional): Arbitrary key-value pairs for custom metadata. + +**Priority**: `name` is the attribute parameter, else the method name. `description` is the attribute parameter, else the docblock — the method name is never used as a description. + +For tool parameter validation and JSON schema generation, see [Schema generation](schemas.md). + +## Tool Return Values + +Tools can return any data type and the SDK will automatically wrap them in appropriate MCP content types. + +### Automatic Content Wrapping + +```php +// Primitive types → TextContent +public function getString(): string { return "Hello"; } // TextContent +public function getNumber(): int { return 42; } // TextContent +public function getBool(): bool { return true; } // TextContent +public function getArray(): array { return ['key' => 'value']; } // TextContent (JSON) + +// Special cases +public function getNull(): ?string { return null; } // TextContent("(null)") +public function returnVoid(): void { /* no return */ } // TextContent("(null)") +``` + +### Explicit Content Types + +For fine control over output formatting: + +```php +use Mcp\Schema\Content\{TextContent, ImageContent, AudioContent, EmbeddedResource}; + +public function getFormattedCode(): TextContent +{ + return TextContent::code(' Date: Fri, 14 Aug 2026 22:16:31 +0200 Subject: [PATCH 04/13] [Docs] Run the docs workflow unfiltered, keep README links relative MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `paths:` filters were mostly noise: `src/**` had to be in the list for the phpDocumentor build, which meant the workflow ran on nearly every PR anyway. They also missed two real inputs — `.phpdoc/template/**` and `composer.lock` — so a template tweak or a phpDocumentor bump could change the rendered site without triggering a build. README links go back to relative repo paths. The README is not part of the Zensical site (`docs_dir` is `docs/`), so those links are only ever resolved by GitHub and Packagist, where absolute URLs break in-repo navigation for no gain. The site pointer and the generated API reference stay absolute. --- .github/workflows/docs.yml | 23 ----------------------- README.md | 18 +++++++++--------- requirements-docs.txt | 5 ----- 3 files changed, 9 insertions(+), 37 deletions(-) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 6c3b51ba..20b6e081 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -4,33 +4,11 @@ name: Documentation # (see mkdocs.yml) and phpDocumentor renders the API reference into /api/. # Pull requests build only — the build is strict, so a broken internal link # fails CI instead of shipping a dead link to the site. -# -# NOTE: deployment uses the official GitHub Pages actions, so the repository's -# Pages source must be set to "GitHub Actions" (Settings → Pages) instead of -# the gh-pages branch this workflow published to before. on: push: branches: [main] - # GitHub Actions does not support YAML anchors, so this list is repeated - # for pull_request below — keep the two in sync. - paths: - - docs/** - - mkdocs.yml - - requirements-docs.txt - - phpdoc.dist.xml - - src/** - - Makefile - - .github/workflows/docs.yml pull_request: - paths: - - docs/** - - mkdocs.yml - - requirements-docs.txt - - phpdoc.dist.xml - - src/** - - Makefile - - .github/workflows/docs.yml workflow_dispatch: permissions: @@ -57,7 +35,6 @@ jobs: uses: "ramsey/composer-install@v4" - name: Install uv - # setup-uv publishes no floating major tag; pin the exact release. uses: astral-sh/setup-uv@v9.0.0 with: enable-cache: true diff --git a/README.md b/README.md index 18b4d552..b6fd5d3d 100644 --- a/README.md +++ b/README.md @@ -170,7 +170,7 @@ $server = Server::builder() ->build(); ``` -[→ Server Documentation](https://php.sdk.modelcontextprotocol.io/run/server-builder/) +[→ Server Documentation](docs/run/server-builder.md) ## Client SDK @@ -284,7 +284,7 @@ $transport = new HttpTransport('http://localhost:8000'); $client->connect($transport); ``` -[→ Client Documentation](https://php.sdk.modelcontextprotocol.io/client/) +[→ Client Documentation](docs/client/index.md) ## Documentation @@ -292,17 +292,17 @@ The full documentation is published at **[php.sdk.modelcontextprotocol.io](https ### Core Concepts -- **[Get started](https://php.sdk.modelcontextprotocol.io/get-started/)** — Install the SDK and build your first server -- **[Servers](https://php.sdk.modelcontextprotocol.io/servers/)** — Tools, resources, resource templates, prompts, and how to register them -- **[Inside your handler](https://php.sdk.modelcontextprotocol.io/handlers/)** — Sampling, logging, progress, and notifications from within a handler -- **[Running your server](https://php.sdk.modelcontextprotocol.io/run/)** — Server builder, STDIO and HTTP transports, framework integration, sessions, authorization -- **[Clients](https://php.sdk.modelcontextprotocol.io/client/)** — Client SDK for connecting to and communicating with MCP servers -- **[Advanced](https://php.sdk.modelcontextprotocol.io/advanced/)** — Events, protocol extensions (including MCP Apps), and custom message handlers +- **[Get started](docs/get-started/index.md)** — Install the SDK and build your first server +- **[Servers](docs/servers/index.md)** — Tools, resources, resource templates, prompts, and how to register them +- **[Inside your handler](docs/handlers/index.md)** — Sampling, logging, progress, and notifications from within a handler +- **[Running your server](docs/run/index.md)** — Server builder, STDIO and HTTP transports, framework integration, sessions, authorization +- **[Clients](docs/client/index.md)** — Client SDK for connecting to and communicating with MCP servers +- **[Advanced](docs/advanced/index.md)** — Events, protocol extensions (including MCP Apps), and custom message handlers - **[API Reference](https://php.sdk.modelcontextprotocol.io/api/)** — Generated class reference ### Learning & Examples -- **[Examples](https://php.sdk.modelcontextprotocol.io/examples/)** — Comprehensive example walkthroughs for servers and clients +- **[Examples](docs/examples.md)** — Comprehensive example walkthroughs for servers and clients - **[ROADMAP.md](ROADMAP.md)** — Planned features and development roadmap ## External Resources diff --git a/requirements-docs.txt b/requirements-docs.txt index db75dafb..ffc1be80 100644 --- a/requirements-docs.txt +++ b/requirements-docs.txt @@ -1,8 +1,3 @@ # Toolchain for the documentation site under `docs/`, built by `make docs`. # -# Zensical is the Material for MkDocs team's successor to MkDocs: it reads the -# same `mkdocs.yml` and renders the same Material theme, but resolves internal -# links against the page tree instead of copying markdown through verbatim. -# -# Pinned exactly: Zensical is pre-1.0, so bumps should be deliberate. zensical==0.0.50 From 12908782eb2ff5dcb09fe4a818ec86df6760dd7f Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Sat, 15 Aug 2026 04:24:43 +0200 Subject: [PATCH 05/13] [Docs] Adopt the doc changes that landed on main into the new structure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Twelve commits landed while the restructure was open, seven of them touching the flat guides this branch replaces. Renames merged on their own; the three files that were split apart needed their new sections placed by hand: - Connection retries, protocol version negotiation, sampling with tools and roots from `client.md` into `client/connecting.md` and `client/server-requests.md` - `ResourceLink` and structured output from `mcp-elements.md` into `servers/tools.md` - Protocol version negotiation from `server-builder.md` into `run/server-builder.md` The `setMaxRetries()` note claiming the value is never acted on is gone — #413 implemented it. Callouts became admonitions and cross-links were repointed at the new paths, as everywhere else in this branch. --- docs/client/connecting.md | 41 ++++++++++++++-- docs/client/errors.md | 2 +- docs/client/server-requests.md | 81 +++++++++++++++++++++++++++++++ docs/run/server-builder.md | 68 ++++++++++++++++++++++++++ docs/servers/tools.md | 89 +++++++++++++++++++++++++++++++++- 5 files changed, 275 insertions(+), 6 deletions(-) diff --git a/docs/client/connecting.md b/docs/client/connecting.md index 22bc011d..c3dd802c 100644 --- a/docs/client/connecting.md +++ b/docs/client/connecting.md @@ -17,12 +17,31 @@ $client = Client::builder() ->setClientInfo('My Application', '1.0.0', 'Description of my client') ->setInitTimeout(30) // Seconds to wait for initialization ->setRequestTimeout(120) // Seconds to wait for request responses + ->setMaxRetries(3) // Retries for failed connections ->build(); ``` -!!! note - The builder also exposes `setMaxRetries()`, but the value is currently stored and never acted on — no transport - retries a failed connection. Do not rely on it. +### Connection Retries + +`setMaxRetries()` controls how often `connect()` retries a failed connection. It +counts retries rather than attempts, so the default of `3` means one initial +attempt plus up to three retries — four in total — before the `ConnectionException` +of the last attempt is rethrown: + +```php +$client = Client::builder() + ->setMaxRetries(0) // Fail on the first failed attempt + ->build(); +``` + +Between two attempts the transport is closed, so a retry never reuses a +half-established connection: a `StdioTransport` spawns a fresh server process and +an `HttpTransport` discards the session ID of the failed attempt. Each retry is +preceded by a short, linearly growing delay (100ms, 200ms, 300ms, …). + +Only the connection handshake is retried. Individual requests such as +`callTool()` are always sent once — retrying them is unsafe as tool calls are not +necessarily idempotent. ### Client Information @@ -40,7 +59,7 @@ $client = Client::builder() ### Protocol Version -Specify the MCP protocol version (defaults to latest): +Specify the MCP protocol version to offer during the handshake (defaults to the latest): ```php use Mcp\Schema\Enum\ProtocolVersion; @@ -50,6 +69,20 @@ $client = Client::builder() ->build(); ``` +This is an offer, not a demand. A server that does not support the requested revision counter-offers one it does, as +described in the specification's +[protocol version negotiation](https://modelcontextprotocol.io/specification/draft/basic/versioning#protocol-version-negotiation) +section. The client accepts any counter-offer it knows about and continues on that revision; a counter-offer the SDK +cannot speak fails the handshake with a `ConnectionException` rather than continuing on a revision neither side agreed +on. Use `$client->getProtocolVersion()` after connecting to read what was actually negotiated. + +Modern revisions such as `2026-07-28` replaced `initialize` with per-request metadata, so they cannot be offered here. +Configuring one still opens the handshake with `ProtocolVersion::latestHandshake()`, and the client logs a warning +saying so. + +See [Protocol Version Negotiation](../run/server-builder.md#protocol-version-negotiation) for the server side of the +exchange. + ### Capabilities Declare client capabilities to enable server features: diff --git a/docs/client/errors.md b/docs/client/errors.md index 0b3aff0b..94e27f99 100644 --- a/docs/client/errors.md +++ b/docs/client/errors.md @@ -74,7 +74,7 @@ $samplingCallback = new class implements SamplingCallbackInterface { role: Role::Assistant, content: new TextContent($response), model: 'mock-llm', - stopReason: 'end_turn', + stopReason: 'endTurn', ); } catch (\Throwable $e) { throw new SamplingException( diff --git a/docs/client/server-requests.md b/docs/client/server-requests.md index 39637acf..f0d2b879 100644 --- a/docs/client/server-requests.md +++ b/docs/client/server-requests.md @@ -84,6 +84,42 @@ $client = Client::builder() ->build(); ``` +### Sampling with Tools + +Clients that support tool-enabled sampling should advertise that capability and forward the request's `tools` and +`toolChoice` fields to their LLM provider. A provider response that requests tools can be returned as one or more +`ToolUseContent` blocks: + +```php +use Mcp\Schema\ClientCapabilities; +use Mcp\Schema\Content\ToolUseContent; +use Mcp\Schema\Enum\Role; +use Mcp\Schema\Result\CreateSamplingMessageResult; + +$client = Client::builder() + ->setCapabilities(new ClientCapabilities( + sampling: true, + samplingContext: true, + samplingTools: true, + )) + ->addRequestHandler(new SamplingRequestHandler($samplingCallback)) + ->build(); + +// Inside the sampling callback, after invoking the LLM provider: +return new CreateSamplingMessageResult( + role: Role::Assistant, + content: array_map( + static fn ($call) => new ToolUseContent($call->id, $call->name, $call->input), + $providerResponse->toolCalls, + ), + model: $providerResponse->model, + stopReason: 'toolUse', +); +``` + +The server executes the requested tools and sends their results in a later sampling request as `ToolResultContent` +blocks in a user message. The client should pass those blocks back to the LLM provider to continue the sampling loop. + !!! warning **Error Handling in Sampling Callbacks:** @@ -167,3 +203,48 @@ Only the `Accept` action carries content. See `examples/client/stdio_elicitation.php` for a runnable example against the elicitation demo server. + +## Roots + +Roots let the client expose a list of `file://` "workspace folders" that the server +is allowed to operate on. Advertise the `roots` capability and register a handler +that answers server `roots/list` requests: + +```php +use Mcp\Client\Handler\Request\ListRootsRequestHandler; +use Mcp\Client\Handler\Request\RootsCallbackInterface; +use Mcp\Schema\ClientCapabilities; +use Mcp\Schema\Request\ListRootsRequest; +use Mcp\Schema\Result\ListRootsResult; +use Mcp\Schema\Root; + +class WorkspaceRootsCallback implements RootsCallbackInterface +{ + public function __invoke(ListRootsRequest $request): ListRootsResult + { + return new ListRootsResult([ + new Root('file:///home/user/projects/app', 'Application'), + new Root('file:///home/user/projects/library', 'Library'), + ]); + } +} + +$client = Client::builder() + ->setCapabilities(new ClientCapabilities(roots: true, rootsListChanged: true)) + ->addRequestHandler(new ListRootsRequestHandler(new WorkspaceRootsCallback)) + ->build(); +``` + +When the client's roots change, notify the server so it can request the updated +list via `roots/list`. This requires advertising the `roots.listChanged` +capability (`rootsListChanged: true` above); otherwise `sendRootsListChanged()` +throws a `RuntimeException`. On a client that is not connected it throws a +`ConnectionException`: + +```php +$client->sendRootsListChanged(); +``` + +See `examples/client/stdio_roots.php` for a runnable example: it calls the +`inspect_workspace_roots` tool of the client-communication demo server, which +answers by issuing the `roots/list` request back to the client. diff --git a/docs/run/server-builder.md b/docs/run/server-builder.md index 17aa602f..65516aca 100644 --- a/docs/run/server-builder.md +++ b/docs/run/server-builder.md @@ -78,6 +78,73 @@ $server = Server::builder() ->setInstructions('This calculator supports basic arithmetic operations. Use the calculate tool for math operations and check the config resource for current settings.'); ``` +### Protocol Version + +By default the server negotiates the protocol revision with each client during the `initialize` handshake, and you do +not need to configure anything. See [Protocol Version Negotiation](#protocol-version-negotiation) below for how that +negotiation resolves, and for what `setProtocolVersion()` changes: + +```php +use Mcp\Schema\Enum\ProtocolVersion; + +$server = Server::builder() + ->setProtocolVersion(ProtocolVersion::V2025_06_18); +``` + +## Protocol Version Negotiation + +MCP revisions are identified by a date string such as `2025-11-25`. The client names the revision it wants to speak in +its `initialize` request, and the server answers with the revision the connection will actually use. Both sides +disconnect if they cannot agree. This follows the +[protocol version negotiation](https://modelcontextprotocol.io/specification/draft/basic/versioning#protocol-version-negotiation) +section of the specification. + +The SDK's known revisions live in `Mcp\Schema\Enum\ProtocolVersion`, declared oldest to newest: + +```php +use Mcp\Schema\Enum\ProtocolVersion; + +ProtocolVersion::latestHandshake(); // newest revision reachable via `initialize` +ProtocolVersion::handshakeVersions(); // every revision the server will negotiate, oldest first +ProtocolVersion::V2025_11_25->isAtLeast(ProtocolVersion::V2025_06_18); // true +``` + +Comparisons go through declaration order rather than string collation. The identifiers happen to be ISO dates today, +but they are an enumerated set rather than an ordered scalar, so nothing should assume they sort chronologically. + +### How the server answers + +| Client requests | Server responds with | +| --- | --- | +| A revision the server supports | That same revision | +| An unknown or malformed revision | `ProtocolVersion::latestHandshake()` as a counter-offer | +| A modern revision such as `2026-07-28` | `ProtocolVersion::latestHandshake()` as a counter-offer | + +A counter-offer is not an error: the client decides whether it can continue on the offered revision or must close the +connection. The negotiated revision is stored on the session under `protocol_version`. + +The last row is not a rejection of an unknown revision — the SDK knows `2026-07-28`, it just cannot be reached through +this handshake. The modern era replaced `initialize` with per-request metadata, so answering with one of its revisions +would leave a connection neither side could use. Serving that era is separate work; today the server only knows not to +mis-negotiate it. + +This table is mirrored by the `provideNegotiationTable()` data provider in +`tests/Unit/Server/Handler/Request/InitializeHandlerTest.php`, which drives its supported-revision rows off the enum so +a newly declared revision is covered automatically. + +### Pinning a revision + +`setProtocolVersion()` pins the handshake to exactly one revision instead of negotiating across the supported set. The +pin wins over the client's request, so a client asking for anything else receives the pinned revision as a +counter-offer and has to decide whether to continue. Leave it unset unless you have a reason to refuse other revisions. + +!!! note + On the Streamable HTTP transport, every request after the handshake also carries an `MCP-Protocol-Version` header, + which is validated separately by `ProtocolVersionMiddleware`. The pin does not reach that check: the transport + builds the middleware without access to the server configuration, so the header keeps being accepted for every + revision in `ProtocolVersion::handshakeVersions()`. To narrow it too, construct the middleware yourself with the + same revision — see [Protocol Version Validation](http.md#protocol-version-validation). + ## Discovery Configuration **Required when using MCP attributes.** If you're using PHP attributes (`#[McpTool]`, `#[McpResource]`, `#[McpResourceTemplate]`, `#[McpPrompt]`) to define your MCP elements, you **MUST** configure discovery to tell the server where to look for these attributes. @@ -245,6 +312,7 @@ $server = Server::builder() | `setServerInfo()` | name, version, description? | Set server identity | | `setPaginationLimit()` | limit | Set max items per page | | `setInstructions()` | instructions | Set usage instructions | +| `setProtocolVersion()` | protocolVersion | Pin the handshake to one protocol revision | | `setDiscovery()` | basePath, scanDirs?, excludeDirs?, cache? | Configure attribute discovery | | `setSession()` | sessionStore?, sessionManager?, gcProbability?, gcDivisor? | Configure session management | | `setLogger()` | logger | Set PSR-3 logger | diff --git a/docs/servers/tools.md b/docs/servers/tools.md index c63a8ac3..b8a40979 100644 --- a/docs/servers/tools.md +++ b/docs/servers/tools.md @@ -60,7 +60,7 @@ public function returnVoid(): void { /* no return */ } // TextContent( For fine control over output formatting: ```php -use Mcp\Schema\Content\{TextContent, ImageContent, AudioContent, EmbeddedResource}; +use Mcp\Schema\Content\{TextContent, ImageContent, AudioContent, ResourceLink, EmbeddedResource}; public function getFormattedCode(): TextContent { @@ -97,6 +97,17 @@ public function getEmbeddedResource(): EmbeddedResource // new TextResourceContents('file://data.json', 'application/json', '{}') // ); } + +public function getResourceLink(): ResourceLink +{ + // Reference a resource by URI without embedding its contents, e.g. when + // a tool result would otherwise need to inline many or large resources. + return new ResourceLink( + uri: 'file://data.json', + name: 'data.json', + mimeType: 'application/json' + ); +} ``` ### Multiple Content Items @@ -114,6 +125,82 @@ public function getMultipleContent(): array } ``` +### Structured Output + +Besides the human-readable `content`, a tool result can carry a machine-readable `structuredContent` value. Declare its +shape with `outputSchema`, a JSON Schema of type `object`: + +```php +#[McpTool( + name: 'get_weather', + outputSchema: [ + 'type' => 'object', + 'properties' => [ + 'temperature' => ['type' => 'number'], + 'conditions' => ['type' => 'string'], + ], + 'required' => ['temperature', 'conditions'], + ] +)] +public function getWeather(string $city): array +{ + // Sent as `structuredContent`, and JSON-encoded into `content` for clients that ignore it + return ['temperature' => 22.5, 'conditions' => 'sunny']; +} +``` + +The same schema can be passed to manual registration: + +```php +$builder->addTool([WeatherHandler::class, 'getWeather'], outputSchema: [/* ... */]); +``` + +The SDK fills `structuredContent` whenever the return value qualifies — `outputSchema` is what tells clients to expect it +and lets them validate it. What qualifies depends on the protocol revision the call is served under: + +| Return value | `structuredContent` | +|---|---| +| Associative array (`['temperature' => 22.5]`) | The array | +| Object (`stdClass`, DTO, `JsonSerializable`) that serializes to a JSON object | Its JSON representation | +| List (`[1, 2, 3]`, `[['id' => 1], ['id' => 2]]`), or an object serializing to one | Omitted before `2026-07-28`, kept from it on | +| Array holding `Content` instances | Omitted (already carried in `content`) | +| Scalars, `null`, `Content` instances | Omitted | + +Up to revision `2025-11-25`, `structuredContent` had to be a JSON object, so a PHP list — which serializes to a JSON +array — was not emittable and strict clients rejected the whole tool call over one. [SEP-2106][sep-2106], part of +revision `2026-07-28`, widened `outputSchema` to any JSON Schema 2020-12 and `structuredContent` to any JSON value +conforming to it. The SDK picks the rule from the revision negotiated for the call, so a tool serving both eras needs the +object shape to produce structured output everywhere. Wrap the list in a key for that: + +```php +// Structured content only from 2026-07-28 on: a bare list is not a JSON object +public function listUsersFlat(): array +{ + return [['id' => 1], ['id' => 2]]; +} + +#[McpTool(outputSchema: [ + 'type' => 'object', + 'properties' => [ + 'items' => ['type' => 'array', 'items' => ['type' => 'object']], + ], + 'required' => ['items'] +])] +public function listUsers(): array +{ + return ['items' => [['id' => 1], ['id' => 2]]]; +} +``` + +Either way the data reaches the client: a return value with no structured representation is still JSON-encoded into +`content` as a `TextContent`. When a tool declares an `outputSchema` but returns something that cannot be sent as +`structuredContent`, the SDK logs a warning — the value is not silently dropped. + +A tool that wants to branch on the revision itself can read it from the injected `RequestContext`, see +[Talking back to the client](../handlers/client-communication.md#clientgateway). + +[sep-2106]: https://modelcontextprotocol.io/specification/2026-07-28/server/tools#structured-content + ### Error Handling Tool handlers can throw any exception, but the type determines how it's handled: From 0929c33d86417d3c7f57cce3b24bc2aac08bb118 Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Sat, 15 Aug 2026 04:27:15 +0200 Subject: [PATCH 06/13] [Docs] Document the elicitation and roots client examples Both were missing from the examples guide: the elicitation client predates this branch, the roots client arrived with #395. --- docs/examples.md | 103 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 103 insertions(+) diff --git a/docs/examples.md b/docs/examples.md index f630d922..8cfd0357 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -512,3 +512,106 @@ php examples/client/http_client_communication.php !!! note For sampling with HTTP transport, the server must support concurrent request processing (e.g., using Symfony CLI, PHP-FPM, or a production web server). PHP's built-in development server cannot handle the concurrent requests required for sampling. + +### STDIO Elicitation + +**File**: `examples/client/stdio_elicitation.php` + +**What it demonstrates:** +- Answering server-initiated `elicitation/create` requests +- Prompting interactively on STDIN, one field per requested property +- Deriving a default per schema type, applied when the user just presses Enter +- Casting the entered string back to the declared type + +Runs against the [Elicitation](#elicitation) server example, whose tools ask for +input mid-execution. + +**Key Features:** +```php +use Mcp\Client\Handler\Request\ElicitationCallbackInterface; +use Mcp\Client\Handler\Request\ElicitationRequestHandler; +use Mcp\Schema\ClientCapabilities; +use Mcp\Schema\Enum\ElicitAction; +use Mcp\Schema\Request\ElicitRequest; +use Mcp\Schema\Result\ElicitResult; + +$elicitationRequestHandler = new ElicitationRequestHandler(new class implements ElicitationCallbackInterface { + public function __invoke(ElicitRequest $request): ElicitResult + { + echo "\n[ELICIT] {$request->message}\n"; + + $content = []; + foreach ($request->requestedSchema->properties as $name => $definition) { + // defaultFor() and cast() below switch on the schema definition type + $default = $this->defaultFor($definition); + echo " {$definition->title} [{$default}]: "; + + $input = trim(fgets(\STDIN) ?: ''); + $content[$name] = '' === $input ? $default : $this->cast($definition, $input); + } + + return new ElicitResult(ElicitAction::Accept, $content); + } +}); + +$client = Client::builder() + ->setCapabilities(new ClientCapabilities(elicitation: true)) + ->addRequestHandler($elicitationRequestHandler) + ->build(); +``` + +**Usage:** +```bash +# Run the client (automatically starts the elicitation server) +php examples/client/stdio_elicitation.php +``` + +The client calls `book_restaurant` and `confirm_action`, so it prompts for a +multi-field reservation form and then for a boolean confirmation. + +### STDIO Roots + +**File**: `examples/client/stdio_roots.php` + +**What it demonstrates:** +- Advertising the `roots` capability during initialization +- Answering server `roots/list` requests with `file://` workspace folders +- Notifying the server when the list of roots changes + +**Key Features:** +```php +use Mcp\Client\Handler\Request\ListRootsRequestHandler; +use Mcp\Client\Handler\Request\RootsCallbackInterface; +use Mcp\Schema\ClientCapabilities; +use Mcp\Schema\Result\ListRootsResult; +use Mcp\Schema\Root; + +$rootsRequestHandler = new ListRootsRequestHandler(new class implements RootsCallbackInterface { + public function __invoke(ListRootsRequest $request): ListRootsResult + { + echo "[ROOTS] Server requested the client's list of roots\n"; + + return new ListRootsResult([ + new Root('file:///home/user/projects/app', 'Application'), + new Root('file:///home/user/projects/library', 'Library'), + ]); + } +}); + +$client = Client::builder() + ->setCapabilities(new ClientCapabilities(roots: true, rootsListChanged: true)) + ->addRequestHandler($rootsRequestHandler) + ->build(); + +// The tool asks the client for its roots, which triggers the handler above +$result = $client->callTool(name: 'inspect_workspace_roots'); + +// Whenever the workspace folders change +$client->sendRootsListChanged(); +``` + +**Usage:** +```bash +# Run the client (automatically starts the communication server) +php examples/client/stdio_roots.php +``` From e8908267700a8d994fe9786315603ecdf86e5814 Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Wed, 19 Aug 2026 03:41:23 +0200 Subject: [PATCH 07/13] [Docs] Fold the 2026-07-28 lifecycle into the new structure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Main's stateless-lifecycle.md becomes a docs/lifecycle/ section, and the pages main touched — deprecations, the middleware split, the client's modern era — land where the restructure moved them. --- README.md | 1 + docs/client/connecting.md | 17 +- docs/client/index.md | 2 + docs/client/server-requests.md | 6 + docs/examples.md | 106 ++++++- docs/handlers/client-communication.md | 12 + docs/handlers/index.md | 3 + docs/handlers/logging.md | 8 + docs/index.md | 2 + docs/lifecycle/caching.md | 25 ++ docs/lifecycle/client.md | 47 ++++ docs/lifecycle/index.md | 94 +++++++ docs/lifecycle/input-required.md | 107 +++++++ docs/lifecycle/requests.md | 97 +++++++ docs/lifecycle/serving-both-eras.md | 62 ++++ docs/lifecycle/subscriptions.md | 36 +++ docs/run/http.md | 19 +- docs/run/index.md | 4 + docs/run/server-builder.md | 72 ++++- docs/run/sessions.md | 5 + docs/stateless-lifecycle.md | 391 -------------------------- mkdocs.yml | 8 + 22 files changed, 709 insertions(+), 415 deletions(-) create mode 100644 docs/lifecycle/caching.md create mode 100644 docs/lifecycle/client.md create mode 100644 docs/lifecycle/index.md create mode 100644 docs/lifecycle/input-required.md create mode 100644 docs/lifecycle/requests.md create mode 100644 docs/lifecycle/serving-both-eras.md create mode 100644 docs/lifecycle/subscriptions.md delete mode 100644 docs/stateless-lifecycle.md diff --git a/README.md b/README.md index b6fd5d3d..7372fcdd 100644 --- a/README.md +++ b/README.md @@ -297,6 +297,7 @@ The full documentation is published at **[php.sdk.modelcontextprotocol.io](https - **[Inside your handler](docs/handlers/index.md)** — Sampling, logging, progress, and notifications from within a handler - **[Running your server](docs/run/index.md)** — Server builder, STDIO and HTTP transports, framework integration, sessions, authorization - **[Clients](docs/client/index.md)** — Client SDK for connecting to and communicating with MCP servers +- **[The 2026-07-28 lifecycle](docs/lifecycle/index.md)** — The stateless protocol revision: per-request metadata, `server/discover`, multi round-trip requests, caching and subscriptions - **[Advanced](docs/advanced/index.md)** — Events, protocol extensions (including MCP Apps), and custom message handlers - **[API Reference](https://php.sdk.modelcontextprotocol.io/api/)** — Generated class reference diff --git a/docs/client/connecting.md b/docs/client/connecting.md index c3dd802c..1d22febe 100644 --- a/docs/client/connecting.md +++ b/docs/client/connecting.md @@ -2,7 +2,9 @@ A client is configured once through its builder, then connected to a [transport](transports.md). Connecting performs the MCP initialization handshake, after -which the server's capabilities are known and its elements can be used. +which the server's capabilities are known and its elements can be used. On protocol +revision `2026-07-28` there is no handshake to perform — see +[Clients on this revision](../lifecycle/client.md). ## Client Builder @@ -76,9 +78,9 @@ section. The client accepts any counter-offer it knows about and continues on th cannot speak fails the handshake with a `ConnectionException` rather than continuing on a revision neither side agreed on. Use `$client->getProtocolVersion()` after connecting to read what was actually negotiated. -Modern revisions such as `2026-07-28` replaced `initialize` with per-request metadata, so they cannot be offered here. -Configuring one still opens the handshake with `ProtocolVersion::latestHandshake()`, and the client logs a warning -saying so. +Setting a modern revision such as `2026-07-28` selects the other lifecycle rather than making an offer: there is no +`initialize` to negotiate with, so `connect()` sends none and every request carries its own revision instead. Nothing +else about the client API changes. See [Clients on this revision](../lifecycle/client.md) for what happens underneath. See [Protocol Version Negotiation](../run/server-builder.md#protocol-version-negotiation) for the server side of the exchange. @@ -119,7 +121,9 @@ $client = Client::builder() ### Request Handlers -Register handlers for server-initiated requests (e.g., sampling): +Register handlers for server-initiated requests (e.g., sampling). The same handlers answer a +[multi round-trip](../lifecycle/input-required.md) `input_required` result on a modern revision, where the server +returns its ask instead of sending a request: ```php use Mcp\Client\Handler\Request\SamplingRequestHandler; @@ -169,6 +173,9 @@ The `connect()` method performs the MCP initialization handshake: 3. Waits for InitializeResult from server 4. Sends InitializedNotification +On a modern revision it opens the transport and asks `server/discover` for the server's identity instead; a server +that does not answer that optional method still yields a usable connection. + !!! warning Always wrap connection in try/catch to handle `ConnectionException` for failed connections. diff --git a/docs/client/index.md b/docs/client/index.md index 3bd172d1..bdd62356 100644 --- a/docs/client/index.md +++ b/docs/client/index.md @@ -37,3 +37,5 @@ $client->disconnect(); messages, sampling requests, and elicitations the server sends *you*. * **[Error handling](errors.md)** — which exception means what, plus a complete end-to-end example. +* **[Clients on this revision](../lifecycle/client.md)** — the one builder line that speaks + protocol revision `2026-07-28`, and what it changes underneath. diff --git a/docs/client/server-requests.md b/docs/client/server-requests.md index f0d2b879..decd48fc 100644 --- a/docs/client/server-requests.md +++ b/docs/client/server-requests.md @@ -4,6 +4,8 @@ The client can receive requests and notifications from the server when configure ## Logging Notifications +> **Deprecated** since protocol revision `2026-07-28` ([SEP-2577](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577)), earliest removal `2027-07-28`. Logging keeps working until then; new integrations should log to stderr (stdio) or use OpenTelemetry instead. + Receive structured log messages from the server: ```php @@ -37,6 +39,8 @@ $client->setLoggingLevel(LoggingLevel::Info); ## Sampling (LLM Requests) +> **Deprecated** since protocol revision `2026-07-28` ([SEP-2577](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577)), earliest removal `2027-07-28`. Sampling keeps working until then; new integrations should call an LLM provider's API directly instead. + Handle server requests for LLM completions: ```php @@ -206,6 +210,8 @@ elicitation demo server. ## Roots +> **Deprecated** since protocol revision `2026-07-28` ([SEP-2577](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577)), earliest removal `2027-07-28`. Roots keep working until then; new integrations should pass directories or files through tool arguments, resource URIs or server configuration instead. + Roots let the client expose a list of `file://` "workspace folders" that the server is allowed to operate on. Advertise the `roots` capability and register a handler that answers server `roots/list` requests: diff --git a/docs/examples.md b/docs/examples.md index 8cfd0357..7902d581 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -164,6 +164,8 @@ $server = Server::builder() - Server initiated communication back to the client - Logging, sampling, progress and notifications - Using `ClientGateway` in tool method via method argument injection of `RequestContext` +- Sampling and roots asked for the [multi round-trip](lifecycle/input-required.md) way, so the + same tools serve a handshake-era and a `2026-07-28` client without naming either ### Discovery User Profile @@ -282,7 +284,8 @@ public function formatText( **File**: `examples/server/elicitation/` **What it demonstrates:** -- Server-to-client elicitation requests +- Asking the user for input from a tool, written the + [multi round-trip](lifecycle/input-required.md) way so one handler serves both protocol eras - Interactive user input during tool execution - Multi-field form schemas with validation - Boolean confirmation dialogs @@ -319,11 +322,15 @@ $schema = new ElicitationSchema( required: ['party_size', 'date'] ); -// Send elicitation request -$result = $client->elicit( - message: 'Please provide your reservation details', - requestedSchema: $schema -); +// Return the ask, read the answer off the retry. The example wraps both halves +// in one helper, since every tool here does the same thing: +$result = $context->getInputContext()?->elicitResult('details') + ?? new InputRequiredResult(['details' => new ElicitRequest($message, $schema)]); + +// First round: hand the ask back to the client, which retries this whole call. +if ($result instanceof InputRequiredResult) { + return $result; +} // Handle response if ($result->isAccepted()) { @@ -334,8 +341,12 @@ if ($result->isAccepted()) { ``` **Important Notes:** -- Elicitation requires a session store (e.g., `FileSessionStore`) -- Check client capabilities with `supportsElicitation()` before sending requests +- The handler is re-entered from the top on the retry, so anything that must happen once + belongs behind the "do I have the answer yet?" check +- A handshake-era client reaches the same tool: the SDK's input-required shim turns the ask + into a real `elicitation/create` request over that connection +- Elicitation over a handshake-era connection requires a session store (e.g., `FileSessionStore`) +- Check client capabilities with `supportsElicitation()` before asking - Schema supports primitive types: string, number/integer, boolean, enum - String fields support format validation: date, date-time, email, uri - Users can accept (providing data), decline, or cancel requests @@ -366,6 +377,42 @@ and calls back into the server. See the [ext-apps repo](https://github.com/modelcontextprotocol/ext-apps) for the TypeScript SDK and richer view-side patterns. +### The 2026-07-28 lifecycle + +**File**: `examples/server/stateless-lifecycle/` + +A server speaking protocol revision `2026-07-28`, which removed the `initialize` handshake and +protocol-level sessions. It is HTTP-only and cannot be driven by the Inspector, which opens with +`initialize`. + +**What it demonstrates:** +- A tool answered in a single POST, with no handshake before it +- A [multi round-trip](lifecycle/input-required.md) tool that returns its ask and reads the answer + off the retry +- Progress and log notifications travelling on the request's own response stream +- Cache hints on `server/discover` and the list methods + +**Usage:** +```bash +php -S 127.0.0.1:8000 examples/server/stateless-lifecycle/server.php +``` + +Every request carries its own protocol version and client capabilities: + +```bash +curl -sS http://127.0.0.1:8000/ \ + -H 'Content-Type: application/json' \ + -H 'Accept: application/json, text/event-stream' \ + -H 'MCP-Protocol-Version: 2026-07-28' \ + -H 'Mcp-Method: server/discover' \ + -d '{"jsonrpc":"2.0","id":1,"method":"server/discover","params":{"_meta":{ + "io.modelcontextprotocol/protocolVersion":"2026-07-28", + "io.modelcontextprotocol/clientCapabilities":{}}}}' +``` + +`tests/Integration/StatelessLifecycleTest.php` drives this example end to end. See +[The 2026-07-28 lifecycle](lifecycle/index.md) for the guide. + ## Client Examples ### STDIO Discovery Calculator (Client) @@ -615,3 +662,46 @@ $client->sendRootsListChanged(); # Run the client (automatically starts the communication server) php examples/client/stdio_roots.php ``` + +### Modern-era client + +**File**: `examples/client/stateless_lifecycle_client.php` + +**What it demonstrates:** +- Selecting protocol revision `2026-07-28` with a single `setProtocolVersion()` call +- A connection that sends no `initialize`, and asks `server/discover` only for the server's identity +- A [multi round-trip](lifecycle/input-required.md) call answered by the client, so the caller sees + one call and one result + +**Key Features:** +```php +$client = Client::builder() + ->setClientInfo('stateless-example-client', '1.0.0') + // The only line that selects the modern lifecycle. + ->setProtocolVersion(ProtocolVersion::V2026_07_28) + // Declared in the envelope of every request, so the server knows what it + // may ask for before it decides how to answer. + ->setCapabilities(new ClientCapabilities(elicitation: true)) + ->addRequestHandler($answerWithAName) + ->build(); + +$client->connect(new HttpTransport('http://127.0.0.1:8000/')); + +// One call from here. Two on the wire: the server returns its question, the +// handler above answers it, and the client retries carrying both the answer and +// the server's sealed `requestState`. +$client->callTool('greet', []); +``` + +Runs against the [2026-07-28 lifecycle](#the-2026-07-28-lifecycle) server example. + +**Usage:** +```bash +# Start the matching server first +php -S 127.0.0.1:8000 examples/server/stateless-lifecycle/server.php + +# Then run the client +php examples/client/stateless_lifecycle_client.php +``` + +See [Clients on this revision](lifecycle/client.md) for what that one builder line changes. diff --git a/docs/handlers/client-communication.md b/docs/handlers/client-communication.md index fc803221..35fde443 100644 --- a/docs/handlers/client-communication.md +++ b/docs/handlers/client-communication.md @@ -3,6 +3,14 @@ MCP supports various ways a server can communicate back to a client on top of the main request-response flow. +> **Protocol revision `2026-07-28`.** This page describes the handshake era, where a server +> sends its own JSON-RPC requests to the client. The modern lifecycle removed that: sampling, +> elicitation and roots are carried back inside the *result* instead, and +> `ClientGateway::sample()`, `elicit()` and `listRoots()` raise a `LogicException` there. +> Logging and progress still work as described below — they simply travel on the request's own +> response stream, and the client opts into each. See +> [Asking for input](../lifecycle/input-required.md). + ## ClientGateway Every communication back to client is handled using the `Mcp\Server\ClientGateway` and its dedicated methods per @@ -38,6 +46,8 @@ if ($context->getProtocolVersion()->isAtLeast(ProtocolVersion::V2026_07_28)) { ## Sampling +> **Deprecated** since protocol revision `2026-07-28` ([SEP-2577](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577)), earliest removal `2027-07-28`. Sampling keeps working until then; new integrations should call an LLM provider's API directly instead. + With [sampling](https://modelcontextprotocol.io/specification/2025-11-25/client/sampling) servers can request clients to execute "completions" or "generations" with a language model for them: @@ -85,6 +95,8 @@ Use `$result->getContentBlocks()` to iterate the response regardless of whether ## Logging +> **Deprecated** since protocol revision `2026-07-28` ([SEP-2577](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577)), earliest removal `2027-07-28`. Logging keeps working until then; new integrations should log to stderr (stdio) or use OpenTelemetry instead. + The [Logging](https://modelcontextprotocol.io/specification/2025-06-18/server/utilities/logging) utility enables servers to send structured log messages as notification to clients: diff --git a/docs/handlers/index.md b/docs/handlers/index.md index a7ae135c..11c70f6d 100644 --- a/docs/handlers/index.md +++ b/docs/handlers/index.md @@ -26,6 +26,9 @@ public function summarize(string $text, RequestContext $context): string call, and sending notifications. * **[Logging](logging.md)** — structured PSR-3 log messages that surface in the client, not in your server's log file. +* **[Asking for input](../lifecycle/input-required.md)** — returning an `InputRequiredResult` + when a handler needs elicitation, sampling or roots. Written that way, one handler serves + both protocol eras. Handlers that need application services (a database connection, an API client) get them from the container instead; see diff --git a/docs/handlers/logging.md b/docs/handlers/logging.md index f6db7fa5..43f3bea4 100644 --- a/docs/handlers/logging.md +++ b/docs/handlers/logging.md @@ -1,9 +1,17 @@ # Logging +> **Deprecated** since protocol revision `2026-07-28` ([SEP-2577](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577)), earliest removal `2027-07-28`. Logging keeps working until then; new integrations should log to stderr (stdio) or use OpenTelemetry instead. + The SDK provides support to send log messages to clients. All standard PSR-3 log levels are supported. Level **warning** is the default level, so anything below it is dropped until the client raises the level with `logging/setLevel`. +!!! note + Under the [2026-07-28 lifecycle](../lifecycle/requests.md#progress-and-logging) there is no + `logging/setLevel`: the client names its level in each request's + `_meta["io.modelcontextprotocol/logLevel"]`, and a request naming none receives no log + notifications at all. + !!! note Only the message is forwarded to the client. A PSR-3 `$context` array is accepted for interface compatibility but is **not** sent — interpolate anything you need into the message itself. diff --git a/docs/index.md b/docs/index.md index ef5ed500..341e635d 100644 --- a/docs/index.md +++ b/docs/index.md @@ -95,6 +95,8 @@ around in. **[Inside your handler](handlers/index.md)**. * Building the other side, an application that *uses* MCP servers, is **[Clients](client/index.md)**. +* The stateless protocol revision — no handshake, no sessions — is + **[The 2026-07-28 lifecycle](lifecycle/index.md)**. * Complete, runnable projects are in **[Examples](examples.md)**. * Hunting for an exact signature? The **[API Reference](https://php.sdk.modelcontextprotocol.io/api/)** is generated from the source. diff --git a/docs/lifecycle/caching.md b/docs/lifecycle/caching.md new file mode 100644 index 00000000..51ae8057 --- /dev/null +++ b/docs/lifecycle/caching.md @@ -0,0 +1,25 @@ +# Caching + +`server/discover`, the four list methods and `resources/read` **must** carry `ttlMs` and +`cacheScope`. The default is `ttlMs: 0, cacheScope: "private"` — conformant, and a flat +refusal to let anything be cached. Say what you actually mean: + +```php +use Mcp\Schema\Enum\CacheScope; +use Mcp\Server\Wire\CachePolicy; + +->setCachePolicy( + CachePolicy::default(30_000) + ->withMethod('tools/list', 3_600_000, CacheScope::Public) + ->withMethod('server/discover', 3_600_000, CacheScope::Public), +) +``` + +`public` lets a shared proxy serve one caller's answer to another, so use it only for +results that do not vary by caller. Only the operator can make that call, which is why the +conservative default stands until you change it. + +A `ReadResourceResult` may set its own `ttlMs`/`cacheScope`, which win over the policy. + +Results produced by an [MRTR retry](input-required.md) are never given hints: their inputs +are not part of any cache key. diff --git a/docs/lifecycle/client.md b/docs/lifecycle/client.md new file mode 100644 index 00000000..68aa7ac6 --- /dev/null +++ b/docs/lifecycle/client.md @@ -0,0 +1,47 @@ +# Clients on this revision + +One line selects the lifecycle; nothing else about the [client API](../client/index.md) +changes. + +```php +$client = Client::builder() + ->setClientInfo('my-client', '1.0.0') + ->setProtocolVersion(ProtocolVersion::V2026_07_28) + ->setCapabilities(new ClientCapabilities(elicitation: true)) + ->addRequestHandler($myElicitationHandler) + ->build(); + +$client->connect(new HttpTransport('https://example.com/mcp')); + +$client->callTool('greet', []); +``` + +What that changes underneath: + +- **No handshake.** `connect()` sends no `initialize`. It asks `server/discover` only for the + server's identity, and a server that does not answer it still yields a usable connection — + the method is optional. If discovery *does* report `supportedVersions` and the configured + revision is not among them, the client moves to a modern revision the server lists, or + refuses the connection outright rather than talking past it. +- **An envelope on every request**, carrying the revision, the declared capabilities and the + client identity. The capabilities are what let a server decide, per request, whether it may + ask for input. +- **Headers on every POST** — `MCP-Protocol-Version`, `Mcp-Method`, and `Mcp-Name` where the + method addresses a subject. Arguments annotated with `x-mcp-header` are mirrored into + `Mcp-Param-*`, which requires the client to have listed the tool first; `tools/list` is what + populates that knowledge. A tool whose annotations are malformed is dropped from the listing + and refused if called, since the client cannot produce the headers it demands. +- **[Multi round-trip calls](input-required.md) are answered by the client.** A result of + `resultType: "input_required"` is resolved through the same + [request handlers](../client/server-requests.md) that served server-initiated requests in the + handshake era, and the call is re-sent with `inputResponses` and the server's `requestState` + echoed back byte for byte, under a new JSON-RPC id. The caller sees one call and one result. + +Headers are an HTTP concern, so a transport opts into them by implementing +`HeaderAwareTransportInterface`; `HttpTransport` does, `StdioTransport` has nothing to carry +them on. Everything else — the envelope, the skipped handshake, the round-trip loop — applies +to both. + +See +[`examples/client/stateless_lifecycle_client.php`](https://github.com/modelcontextprotocol/php-sdk/blob/main/examples/client/stateless_lifecycle_client.php) +for a runnable version, described in [Examples](../examples.md#modern-era-client). diff --git a/docs/lifecycle/index.md b/docs/lifecycle/index.md new file mode 100644 index 00000000..35407bf4 --- /dev/null +++ b/docs/lifecycle/index.md @@ -0,0 +1,94 @@ +# The 2026-07-28 lifecycle + +Protocol revision `2026-07-28` removed the `initialize` handshake and protocol-level +sessions. Everything a server needs to answer a request now travels *in* that request, +which means any process can answer any request and none of them need to share state. + +Tools, resources, prompts and their handlers are unaffected — the same registrations +serve either lifecycle. What changes is the wire around them, and that is what this +section covers. + +## The two eras + +| | Handshake era (`2025-11-25` and earlier) | Modern era (`2026-07-28`) | +| --- | --- | --- | +| Opening | `initialize` / `notifications/initialized` | none | +| Version | negotiated once, kept on the session | declared on every request | +| Capabilities | exchanged once | declared on every request | +| Discovery | `initialize` result | `server/discover` | +| Sessions | `Mcp-Session-Id` | removed | +| Server → client requests | sent as JSON-RPC requests | returned in the result (MRTR) | +| Change notifications | HTTP `GET` stream, `resources/subscribe` | `subscriptions/listen` | +| Dispatcher | `Protocol` | `StatelessProtocol` | +| HTTP entry | `StreamableHttpTransport` — the same one, for both | + +`ProtocolVersion::isModern()` tells the two apart, and +`Mcp\Schema\Enum\ProtocolVersion::FIRST_MODERN_VERSION` is where the boundary sits. + +## Building a stateless server + +There is nothing to build differently. `Builder::build()` produces a `Server` carrying a +dispatcher for each era, and `StreamableHttpTransport` decides per request which of them +answers: + +```php +use Mcp\Server; +use Mcp\Server\Transport\StreamableHttpTransport; + +$server = Server::builder() + ->setServerInfo('My Server', '1.0.0') + ->addTool(static fn (string $city): string => "17°C in {$city}", name: 'get_weather', description: '…') + ->build(); + +(new SapiEmitter())->emit($server->run(new StreamableHttpTransport($request))); +``` + +That one endpoint answers `initialize` and `server/discover` alike. See +[Serving both eras](serving-both-eras.md) for how the decision is made and how to opt out +of it. + +Modern-era requests accept `POST` only; a `GET` or `DELETE` is a handshake-era session +operation and is routed as one. + +A full example lives in +[`examples/server/stateless-lifecycle/server.php`](https://github.com/modelcontextprotocol/php-sdk/tree/main/examples/server/stateless-lifecycle), +and [Examples](../examples.md#the-2026-07-28-lifecycle) walks through it. + +## The rest of this section + +* **[What travels with a request](requests.md)** — the `_meta` envelope every request + must carry, the headers that mirror it, and how progress and logging become opt-in. +* **[Asking for input](input-required.md)** — multi round-trip requests: how a handler + asks for elicitation, sampling or roots when it cannot interrupt the call to do so. +* **[Caching](caching.md)** — the `ttlMs` / `cacheScope` hints a cacheable result must + carry, and how to say what you actually mean by them. +* **[Subscriptions](subscriptions.md)** — `subscriptions/listen` and the notification bus + that makes delivery work across processes. +* **[Serving both eras](serving-both-eras.md)** — how one endpoint classifies and routes + each request, and how to serve only one era. +* **[Clients on this revision](client.md)** — the one builder line that selects it, and + what it changes underneath. + +## What was removed + +Answered with `404` and `-32601` by a modern server: + +- `initialize`, `notifications/initialized` +- `ping` +- `logging/setLevel` — replaced by `_meta["io.modelcontextprotocol/logLevel"]` +- `resources/subscribe`, `resources/unsubscribe` — replaced by the `resourceSubscriptions` + filter of `subscriptions/listen` +- `notifications/roots/list_changed` + +Also gone: `Mcp-Session-Id`, the HTTP `GET` stream, and SSE resumability (`Last-Event-ID`). +A broken response stream loses the request; the client re-issues it with a new id. + +Error code `-32002` (resource not found) is retired in favour of `-32602`, and must not be +emitted by a server of this revision. The SDK picks the code from the revision serving the +request, so a handshake-era client still gets `-32002`. + +Roots, sampling and logging are all **deprecated** as of this revision. They remain +functional for at least twelve months; new servers should pass directories through tool +arguments or resource URIs instead of roots, integrate with an LLM provider directly +instead of sampling, and log to `stderr` or OpenTelemetry instead of +`notifications/message`. diff --git a/docs/lifecycle/input-required.md b/docs/lifecycle/input-required.md new file mode 100644 index 00000000..7cdce8cb --- /dev/null +++ b/docs/lifecycle/input-required.md @@ -0,0 +1,107 @@ +# Asking for input + +There are no server-initiated requests in this revision. A server that needs sampling, +elicitation or roots **returns** the ask, and the client retries the original call carrying +the answers. The specification calls this a multi round-trip request (MRTR). + +This is the shape to write handlers in even if you also serve handshake-era clients — the +SDK fulfils the same ask over their connection instead. See +[What a handler forks on](#what-a-handler-forks-on). + +```php +use Mcp\Schema\Result\CallToolResult; +use Mcp\Schema\Result\InputRequiredResult; + +static function (RequestContext $context): CallToolResult|InputRequiredResult { + $answer = $context->getInputContext()?->elicitResult('who'); + + if (null === $answer) { + return new InputRequiredResult( + ['who' => new ElicitRequest('Your name?', $schema)], + requestState: $context->mintRequestState(['asked' => 'who']), + ); + } + + return new CallToolResult([new TextContent("Hello, {$answer->content['name']}!")]); +} +``` + +`tools/call`, `prompts/get` and `resources/read` may answer this way; nothing else may. + +## Reading the answers + +`InputContext` hands them back typed — `elicitResult()`, `samplingResult()`, +`rootsResult()` — and returns `null` for an answer that is absent *or* malformed. Both mean +the same thing to a handler: ask again. `response()` is still there for the raw array. + +## `requestState` + +Whatever the server needs to remember between rounds. It travels through the client, so it +is attacker-controlled on return; `mintRequestState()` seals it with an HMAC and a TTL, and +a state that fails verification never reaches a handler. Configure the key with +`Builder::setRequestState()`: + +```php +->setRequestState($_ENV['MCP_REQUEST_STATE_KEY'], ttl: 600) +``` + +The **same key must reach every process that might serve the retry**. A per-process random +value works only for a single-process deployment. Nothing secret belongs in the payload — +it is signed, not encrypted. + +## Capabilities + +A server must not ask for input the client cannot provide. The SDK checks each ask against +the request's declared capabilities and answers `-32021` — with the missing set in +`data.requiredCapabilities` — rather than sending an ask that could never be answered. +Url-mode elicitation needs its own `elicitation.url` declaration; a bare `elicitation` means +form mode only. + +## What not to call + +`ClientGateway::sample()`, `elicit()`, `elicitUrl()` and `listRoots()` belong to the +handshake era. Calling one under this revision raises a `LogicException` naming +`InputRequiredResult` as the replacement. + +## What a handler forks on + +Nothing. Tools, resources, prompts, structured output, progress and errors do not care +which era called, and neither does the one thing that looks like it should: **asking the +user something**. + +Write it the 2026-07-28 way — return an `InputRequiredResult` naming what you need, read the +answer off `RequestContext::getInputContext()` when the call comes back. On a handshake-era +connection the SDK's input-required shim fulfils the same ask over that connection's own +channel: each embedded request goes out as the real `elicitation/create` / +`sampling/createMessage` / `roots/list`, and the handler is re-entered with the answers under +the keys it asked for. It is on by default; +[`examples/server/elicitation`](https://github.com/modelcontextprotocol/php-sdk/tree/main/examples/server/elicitation) +and +[`examples/server/client-communication`](https://github.com/modelcontextprotocol/php-sdk/tree/main/examples/server/client-communication) +are written this way and name no era anywhere. + +Two things to know about it. + +**Re-entry is re-execution.** The handler runs again from the top each round, so it has to +re-derive where it is from what came back rather than from anything it kept. That is already +true of the modern era — the client retries the whole call there — so a portable handler is +written that way regardless. It is only new if you were relying on `ClientGateway::elicit()` +suspending mid-body and keeping your locals; that keeps working untouched, since nothing here +runs unless a handler *returns* an ask. + +**Each round holds the request open.** The shim waits for the client's answer inside the +originating request, which on a process-per-request runtime means it holds a worker for as +long as the user takes. That is the same cost `ClientGateway::elicit()` already pays on that +leg, but the shim makes it reachable from handlers that never mention it — so size +`setInputRequiredLimits()` against your pool. + +```php +$server = Server::builder() + ->setServerInfo('My Server', '1.0.0') + // Re-entries per request, and seconds to wait for one answer. + ->setInputRequiredLimits(maxRounds: 4, roundTimeout: 120) + ->build(); +``` + +`withoutInputRequiredShim()` turns it off, so such a handler fails on a handshake-era +connection instead of being fulfilled behind your back. diff --git a/docs/lifecycle/requests.md b/docs/lifecycle/requests.md new file mode 100644 index 00000000..b1de42a2 --- /dev/null +++ b/docs/lifecycle/requests.md @@ -0,0 +1,97 @@ +# What travels with a request + +There is no handshake to remember anything, so every request carries what the server needs +to answer it: the revision being spoken, what the client can be asked to do, and who is +asking. The HTTP layer mirrors some of that into headers so an intermediary can route +without parsing the body. + +## Per-request metadata + +Every request **must** carry two members in `params._meta`: + +| `_meta` key | Required | Header | +| --- | --- | --- | +| `io.modelcontextprotocol/protocolVersion` | yes | `MCP-Protocol-Version` | +| `io.modelcontextprotocol/clientCapabilities` | yes | — | +| `io.modelcontextprotocol/clientInfo` | no | — | +| `io.modelcontextprotocol/logLevel` | no | — | +| `progressToken` | no | — | +| `traceparent`, `tracestate`, `baggage` | no | — | + +Plus `Mcp-Method` on every request, and `Mcp-Name` on `tools/call`, `prompts/get` and +`resources/read`. A header that disagrees with the body is refused with `-32020`; a missing +required `_meta` member with `-32602`; an unsupported version with `-32022`, carrying the +supported set for the client to retry from. + +Handlers read the metadata through +[`RequestContext`](../handlers/index.md): + +```php +$context->getProtocolVersion(); // the revision serving this request +$context->getClientCapabilities(); // what this client declared, or null in the handshake era +$context->getTraceContext(); // traceparent / tracestate / baggage, verbatim +``` + +`ClientGateway`'s capability probes — `supportsElicitation()`, `supportsSampling()`, +`supportsRoots()` and the sub-capability variants — read the same declaration, so they work +in both eras. + +## Trace context + +`traceparent`, `tracestate` and `baggage` are passed through exactly as they arrived, and +echoed onto every notification the request causes, so a span stays joined across the +response stream. Reading them adds no OpenTelemetry dependency — they are strings: + +```php +['traceparent' => '00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01'] +``` + +## Mirroring a tool argument into a header + +A tool parameter annotated with `x-mcp-header` is mirrored into `Mcp-Param-{Name}` by the +client, and the server checks that the two agree: + +```php +->addTool( + static fn (string $region, string $query): string => …, + name: 'execute_sql', + inputSchema: [ + 'type' => 'object', + 'properties' => [ + 'region' => ['type' => 'string', 'x-mcp-header' => 'Region'], + 'query' => ['type' => 'string'], + ], + 'required' => ['region', 'query'], + ], +) +``` + +The annotation must name a valid HTTP field, be unique case-insensitively, and sit on a +`string`, `integer` or `boolean` property reachable through `properties` keys alone. `Tool` +refuses a definition that breaks any of those rather than letting it fail later as a header +mismatch. See [Schema generation](../servers/schemas.md) for where a hand-written +`inputSchema` fits. + +## Progress and logging + +Both travel on the request's own response stream, and both are opt-in by the client: + +- **Progress** — the client sends `_meta.progressToken`; without one, `$gateway->progress()` + sends nothing. +- **Logging** — the client sends `_meta["io.modelcontextprotocol/logLevel"]`; without it the + server **must not** emit `notifications/message` at all, and does not. This replaced the + `logging/setLevel` request the handshake era used. + +```php +static function (RequestContext $context): string { + $client = $context->getClientGateway(); + $client->log(LoggingLevel::Info, 'Reindexing shard 1 of 3'); + $client->progress(1, 3, 'Shard 1 of 3'); + + return 'done'; +} +``` + +The server answers with a single JSON object when the handler emits nothing, and opens an +SSE stream when it does — so an error that has to carry a specific status still gets one, +and a handler that talks gets a stream. diff --git a/docs/lifecycle/serving-both-eras.md b/docs/lifecycle/serving-both-eras.md new file mode 100644 index 00000000..1917cc2a --- /dev/null +++ b/docs/lifecycle/serving-both-eras.md @@ -0,0 +1,62 @@ +# Serving both eras + +One endpoint serves both, and the client picks nothing. Every request is classified once, +before anything else looks at it, and routed to the lifecycle it belongs to. The decision is +**body-primary**: + +| Evidence | Routed to | +| --- | --- | +| `params._meta` names a modern revision | modern era | +| `params._meta` names a handshake revision | handshake era | +| no such member | handshake era — `initialize` included | +| a notification with no member, under a modern header | modern era | +| `GET` / `DELETE` | handshake era | + +The `MCP-Protocol-Version` header never decides. It is cross-checked against the body, and a +request whose header contradicts its `_meta` is refused with `-32020` before either leg sees +it — the check has to happen at the edge, because a body claiming a handshake revision routes +to a leg that has no such check of its own. A modern header on a request carrying no envelope +is refused with `-32602` naming the member it wants. + +An unrecognised revision goes to whichever leg can answer it best: claimed in the envelope, +the modern leg answers, naming the modern revisions it serves; named only in a header, the +handshake leg answers, naming the handshake ones. + +Both legs come from **one** builder configuration — one registry, one set of handler +instances, one session manager. A tool registered once is reachable from both, and a change +made through one is visible to the other. + +## Middleware + +The [default middleware stack](../run/http.md#default-middleware) runs at the edge, before +the request's era is known, because what it enforces is true of both. `ProtocolVersionMiddleware` +is not in that stack: the `MCP-Protocol-Version` header rule belongs to the handshake era, so +the transport applies it only to requests it classified as handshake-era traffic, and the +modern leg answers for its own revisions. It is available as +`StreamableHttpTransport::handshakeMiddleware()` and is applied whether or not you replace the +edge stack. + +## Serving one era only + +To serve the handshake era alone, say so: + +```php +$server = Server::builder() + ->setServerInfo('My Server', '1.0.0') + ->withoutModernEra() + ->build(); +``` + +That server refuses a modern claim with `-32022`, naming the handshake revisions it does +serve. `setModernVersions()` narrows the modern leg instead of removing it. + +For the opposite — an endpoint that serves the modern era and nothing else — build the +dispatcher on its own and mount it on `StatelessHttpTransport`: + +```php +$protocol = Server::builder() + ->setServerInfo('My Server', '1.0.0') + ->buildStateless([ProtocolVersion::V2026_07_28]); + +(new SapiEmitter())->emit((new StatelessHttpTransport($protocol))->handle($request)); +``` diff --git a/docs/lifecycle/subscriptions.md b/docs/lifecycle/subscriptions.md new file mode 100644 index 00000000..78b772f7 --- /dev/null +++ b/docs/lifecycle/subscriptions.md @@ -0,0 +1,36 @@ +# Subscriptions + +`subscriptions/listen` replaces the HTTP `GET` stream and `resources/subscribe`. The client +opens a long-lived POST whose response stream carries the notification types it asked for; +the server acknowledges first with `notifications/subscriptions/acknowledged`, reporting the +subset it agreed to honour. + +## The notification bus + +Delivery needs a bus, because the process that publishes and the process holding the stream +open are often not the same one: + +```php +use Mcp\Server\Subscription\InMemoryNotificationBus; +use Mcp\Server\Subscription\Psr16NotificationBus; + +// stdio, or a persistent runtime where the whole server is one process +->setNotificationBus(new InMemoryNotificationBus()) + +// PHP-FPM: the publisher and the stream are different workers +->setNotificationBus(new Psr16NotificationBus($cache)) +``` + +Registry changes (`registerTool()`, `unregisterPrompt()`, …) are published automatically. +Anything else — `notifications/resources/updated` above all — is published by the +application: + +```php +$bus->publish(new ResourceUpdatedNotification('file:///project/config.json')); +``` + +## How long a stream lives + +`Builder::setSubscriptionLifetime()` bounds how long a stream is held before the server +closes it gracefully. The real ceiling is the runtime's: under PHP-FPM a stream cannot +outlive `max_execution_time`. Pass `0` for "until the client or the runtime ends it". diff --git a/docs/run/http.md b/docs/run/http.md index 9fc143aa..ada47366 100644 --- a/docs/run/http.md +++ b/docs/run/http.md @@ -56,7 +56,6 @@ When the `middleware` argument is omitted (or set to `null`), the transport inst |-------|------------|---------| | 1 | `CorsMiddleware` | Applies CORS headers to every response. By default does **not** set `Access-Control-Allow-Origin` (cross-origin requests are blocked). | | 2 | `DnsRebindingProtectionMiddleware` | Validates `Origin`/`Host` against an allowlist. Defaults to localhost variants only. | -| 3 | `ProtocolVersionMiddleware` | Rejects requests carrying an unsupported `MCP-Protocol-Version` header with `400 Bad Request`. | ```php // Zero-config, secure-by-default — local servers get full protection automatically. @@ -69,6 +68,13 @@ The default stack can be inspected and recomposed via the public factory: $middleware = StreamableHttpTransport::defaultMiddleware(); ``` +These run at the edge, before the request's protocol era is known, because what they enforce is true of +both eras. `ProtocolVersionMiddleware` is not in that stack: the `MCP-Protocol-Version` header rule belongs +to the handshake era, so the transport applies it only to requests it classified as handshake-era traffic, +and the modern leg answers for its own revisions. It is available as +`StreamableHttpTransport::handshakeMiddleware()` and is applied whether or not you replace the edge stack. +See [Serving both eras](../lifecycle/serving-both-eras.md). + ## CORS Configuration CORS is handled by `CorsMiddleware`. To enable cross-origin browser requests, configure it explicitly and pass it @@ -77,7 +83,6 @@ in place of (or alongside) the defaults: ```php use Mcp\Server\Transport\Http\Middleware\CorsMiddleware; use Mcp\Server\Transport\Http\Middleware\DnsRebindingProtectionMiddleware; -use Mcp\Server\Transport\Http\Middleware\ProtocolVersionMiddleware; use Mcp\Server\Transport\StreamableHttpTransport; // Reflect a specific origin @@ -86,7 +91,6 @@ $transport = new StreamableHttpTransport( middleware: [ new CorsMiddleware(allowedOrigins: ['https://myapp.com']), new DnsRebindingProtectionMiddleware(), - new ProtocolVersionMiddleware(), ], ); @@ -96,7 +100,6 @@ $transport = new StreamableHttpTransport( middleware: [ new CorsMiddleware(allowedOrigins: ['*']), new DnsRebindingProtectionMiddleware(), - new ProtocolVersionMiddleware(), ], ); ``` @@ -133,6 +136,10 @@ or supply a permissive allowlist. set with `400 Bad Request`. Requests without the header pass through, since the `initialize` round-trip and some legacy clients do not send it. +It is applied by the transport itself, to handshake-era traffic only — do not add it to a custom `middleware` +list, where it would run before the era is classified and reject every modern-era request. Construct it +yourself only to narrow the supported set on a server that also pins its handshake: + ```php use Mcp\Schema\Enum\ProtocolVersion; use Mcp\Server\Transport\Http\Middleware\ProtocolVersionMiddleware; @@ -152,6 +159,10 @@ first place. Being separate also means it is unaffected by `setProtocolVersion() the set it was constructed with, not against the revision a given session negotiated, so a server that pins the handshake has to pass that revision here as well. +Modern revisions never reach it. A `2026-07-28` request declares its revision in `params._meta` rather than in a +header, and is routed away from this check entirely — see +[Serving both eras](../lifecycle/serving-both-eras.md). + ## Request Body Size Limit `StreamableHttpTransport` caps the POST body it reads to guard against memory exhaustion from an oversized or diff --git a/docs/run/index.md b/docs/run/index.md index 2b69977f..846c2e22 100644 --- a/docs/run/index.md +++ b/docs/run/index.md @@ -33,3 +33,7 @@ The rest of this section: you serve HTTP from more than one process. * **[Authorization](authorization.md)** — validating OAuth 2 access tokens in front of the HTTP transport. + +The same server also answers protocol revision `2026-07-28`, which has no handshake and no +sessions. Nothing above changes for it; what does is +**[The 2026-07-28 lifecycle](../lifecycle/index.md)**. diff --git a/docs/run/server-builder.md b/docs/run/server-builder.md index 65516aca..89365eba 100644 --- a/docs/run/server-builder.md +++ b/docs/run/server-builder.md @@ -93,6 +93,11 @@ $server = Server::builder() ## Protocol Version Negotiation +This section is about the **handshake era**. Revisions from `2026-07-28` on have no `initialize` and nothing to +negotiate — each request names its own revision. `build()` serves both eras from one configuration; see +[The 2026-07-28 lifecycle](../lifecycle/index.md) and, for narrowing or removing the modern leg, +[Serving both eras](../lifecycle/serving-both-eras.md). + MCP revisions are identified by a date string such as `2025-11-25`. The client names the revision it wants to speak in its `initialize` request, and the server answers with the revision the connection will actually use. Both sides disconnect if they cannot agree. This follows the @@ -125,8 +130,9 @@ connection. The negotiated revision is stored on the session under `protocol_ver The last row is not a rejection of an unknown revision — the SDK knows `2026-07-28`, it just cannot be reached through this handshake. The modern era replaced `initialize` with per-request metadata, so answering with one of its revisions -would leave a connection neither side could use. Serving that era is separate work; today the server only knows not to -mis-negotiate it. +would leave a connection neither side could use. The server does serve that era: a client speaking it sends the +envelope instead of an `initialize` request, and the transport routes it to the modern dispatcher without any +negotiation happening at all. This table is mirrored by the `provideNegotiationTable()` data provider in `tests/Unit/Server/Handler/Request/InitializeHandlerTest.php`, which drives its supported-revision rows off the enum so @@ -139,11 +145,53 @@ pin wins over the client's request, so a client asking for anything else receive counter-offer and has to decide whether to continue. Leave it unset unless you have a reason to refuse other revisions. !!! note - On the Streamable HTTP transport, every request after the handshake also carries an `MCP-Protocol-Version` header, - which is validated separately by `ProtocolVersionMiddleware`. The pin does not reach that check: the transport - builds the middleware without access to the server configuration, so the header keeps being accepted for every - revision in `ProtocolVersion::handshakeVersions()`. To narrow it too, construct the middleware yourself with the - same revision — see [Protocol Version Validation](http.md#protocol-version-validation). + On the Streamable HTTP transport, every handshake-era request after the handshake also carries an + `MCP-Protocol-Version` header, which is validated separately by `ProtocolVersionMiddleware`. The pin does not reach + that check: the transport builds the middleware without access to the server configuration, so the header keeps + being accepted for every revision in `ProtocolVersion::handshakeVersions()`. To narrow it too, construct the + middleware yourself with the same revision — see + [Protocol Version Validation](http.md#protocol-version-validation). + +`setProtocolVersion()` only pins the handshake. To narrow or remove what the modern leg answers for, use +`setModernVersions()` / `withoutModernEra()` — see +[Serving both eras](../lifecycle/serving-both-eras.md#serving-one-era-only). + +## The 2026-07-28 Lifecycle + +`build()` returns a server that answers both protocol eras, and none of the following is required to serve either +one. Each knob is covered in depth in [The 2026-07-28 lifecycle](../lifecycle/index.md): + +```php +use Mcp\Schema\Enum\CacheScope; +use Mcp\Schema\Enum\ProtocolVersion; +use Mcp\Server\Subscription\Psr16NotificationBus; +use Mcp\Server\Wire\CachePolicy; + +$server = Server::builder() + // Signs the state a multi round-trip request carries through the client. + // The same key must reach every process that might serve the retry. + ->setRequestState($_ENV['MCP_REQUEST_STATE_KEY'], ttl: 600) + + // Bounds the input-required shim, which fulfils an `InputRequiredResult` + // over a handshake-era connection. `withoutInputRequiredShim()` turns it off. + ->setInputRequiredLimits(maxRounds: 4, roundTimeout: 120) + + // Caching hints stamped on cacheable results. Defaults to `ttlMs: 0, private`. + ->setCachePolicy(CachePolicy::default(30_000)->withMethod('tools/list', 3_600_000, CacheScope::Public)) + + // Delivery for `subscriptions/listen`, and how long such a stream is held. + ->setNotificationBus(new Psr16NotificationBus($cache)) + ->setSubscriptionLifetime(0) + + // Narrow the modern leg, or drop it entirely. + ->setModernVersions([ProtocolVersion::V2026_07_28]) + ->build(); +``` + +`buildStateless()` returns the modern dispatcher alone, for an endpoint that serves no handshake-era traffic at all. +Its requests are checked against the SEP-2243 standard headers — that `Mcp-Method`, `Mcp-Name` and `Mcp-Param-*` agree +with the body they travel with. `setHeaderValidator(false)` turns that check off, which is needed only when the +dispatcher is served by a transport that has no header layer to check. ## Discovery Configuration @@ -313,6 +361,15 @@ $server = Server::builder() | `setPaginationLimit()` | limit | Set max items per page | | `setInstructions()` | instructions | Set usage instructions | | `setProtocolVersion()` | protocolVersion | Pin the handshake to one protocol revision | +| `setModernVersions()` | versions | Narrow the revisions the modern (2026-07-28) leg answers for | +| `withoutModernEra()` | - | Serve the handshake era only | +| `setRequestState()` | key, ttl? | Signing key and lifetime for multi round-trip request state | +| `setInputRequiredLimits()` | maxRounds, roundTimeout | Bound the input-required shim on handshake-era connections | +| `withoutInputRequiredShim()` | - | Do not fulfil an `InputRequiredResult` over a handshake-era connection | +| `setCachePolicy()` | policy | Set the `ttlMs`/`cacheScope` hints on cacheable results | +| `setNotificationBus()` | bus | Delivery for `subscriptions/listen` streams | +| `setSubscriptionLifetime()` | seconds | How long a subscription stream is held open (`0` = unbounded) | +| `setHeaderValidator()` | enabled | Toggle the SEP-2243 standard-header check on `buildStateless()` | | `setDiscovery()` | basePath, scanDirs?, excludeDirs?, cache? | Configure attribute discovery | | `setSession()` | sessionStore?, sessionManager?, gcProbability?, gcDivisor? | Configure session management | | `setLogger()` | logger | Set PSR-3 logger | @@ -328,3 +385,4 @@ $server = Server::builder() | `addPrompt()` | handler, name?, title?, description?, icons?, meta? | Register prompt | | `add()` | definition, handler | Register an element from a schema VO + handler pair | | `build()` | - | Create the server instance | +| `buildStateless()` | supportedVersions? | Create the modern dispatcher alone, for `StatelessHttpTransport` | diff --git a/docs/run/sessions.md b/docs/run/sessions.md index 929c0233..5bd93464 100644 --- a/docs/run/sessions.md +++ b/docs/run/sessions.md @@ -1,5 +1,10 @@ # Session Management +> Sessions belong to the handshake era. Protocol revision `2026-07-28` removed them, along with +> `Mcp-Session-Id`, because every request carries what is needed to answer it — see +> [The 2026-07-28 lifecycle](../lifecycle/index.md). One server serves both, so the configuration below still +> applies to the handshake-era clients it answers. + Configure session storage and lifecycle. By default, the SDK uses `InMemorySessionStore`: ```php diff --git a/docs/stateless-lifecycle.md b/docs/stateless-lifecycle.md deleted file mode 100644 index 45e3cb2d..00000000 --- a/docs/stateless-lifecycle.md +++ /dev/null @@ -1,391 +0,0 @@ -# The 2026-07-28 lifecycle - -Protocol revision `2026-07-28` removed the `initialize` handshake and protocol-level sessions. Everything a -server needs to answer a request now travels *in* that request, which means any process can answer any -request and none of them need to share state. - -This guide covers what changes for a server author. Tools, resources, prompts and their handlers are -unaffected — the same registrations serve either lifecycle. - -- [The two eras](#the-two-eras) -- [Building a stateless server](#building-a-stateless-server) -- [Per-request metadata](#per-request-metadata) -- [Multi round-trip requests](#multi-round-trip-requests) -- [Progress and logging](#progress-and-logging) -- [Caching](#caching) -- [Subscriptions](#subscriptions) -- [Serving both eras](#serving-both-eras) -- [What was removed](#what-was-removed) - -## The two eras - -| | Handshake era (`2025-11-25` and earlier) | Modern era (`2026-07-28`) | -| --- | --- | --- | -| Opening | `initialize` / `notifications/initialized` | none | -| Version | negotiated once, kept on the session | declared on every request | -| Capabilities | exchanged once | declared on every request | -| Discovery | `initialize` result | `server/discover` | -| Sessions | `Mcp-Session-Id` | removed | -| Server → client requests | sent as JSON-RPC requests | returned in the result (MRTR) | -| Change notifications | HTTP `GET` stream, `resources/subscribe` | `subscriptions/listen` | -| Dispatcher | `Protocol` | `StatelessProtocol` | -| HTTP entry | `StreamableHttpTransport` — the same one, for both | - -`ProtocolVersion::isModern()` tells the two apart, and `Mcp\Schema\Enum\ProtocolVersion::FIRST_MODERN_VERSION` -is where the boundary sits. - -## Building a stateless server - -There is nothing to build differently. `Builder::build()` produces a `Server` carrying a dispatcher for -each era, and `StreamableHttpTransport` decides per request which of them answers: - -```php -use Mcp\Server; -use Mcp\Server\Transport\StreamableHttpTransport; - -$server = Server::builder() - ->setServerInfo('My Server', '1.0.0') - ->addTool(static fn (string $city): string => "17°C in {$city}", name: 'get_weather', description: '…') - ->build(); - -(new SapiEmitter())->emit($server->run(new StreamableHttpTransport($request))); -``` - -That one endpoint answers `initialize` and `server/discover` alike. See -[Serving both eras](#serving-both-eras) for how the decision is made and how to opt out of it. - -Modern-era requests accept `POST` only; a `GET` or `DELETE` is a handshake-era session operation and is -routed as one. - -A full example lives in [`examples/server/stateless-lifecycle/server.php`](../examples/server/stateless-lifecycle/server.php). - -## Per-request metadata - -Every request **must** carry two members in `params._meta`, and the HTTP layer mirrors some of them into -headers so an intermediary can route without parsing the body: - -| `_meta` key | Required | Header | -| --- | --- | --- | -| `io.modelcontextprotocol/protocolVersion` | yes | `MCP-Protocol-Version` | -| `io.modelcontextprotocol/clientCapabilities` | yes | — | -| `io.modelcontextprotocol/clientInfo` | no | — | -| `io.modelcontextprotocol/logLevel` | no | — | -| `progressToken` | no | — | -| `traceparent`, `tracestate`, `baggage` | no | — | - -Plus `Mcp-Method` on every request, and `Mcp-Name` on `tools/call`, `prompts/get` and `resources/read`. -A header that disagrees with the body is refused with `-32020`; a missing required `_meta` member with -`-32602`; an unsupported version with `-32022`, carrying the supported set for the client to retry from. - -Handlers read the metadata through `RequestContext`: - -```php -$context->getProtocolVersion(); // the revision serving this request -$context->getClientCapabilities(); // what this client declared, or null in the handshake era -$context->getTraceContext(); // traceparent / tracestate / baggage, verbatim -``` - -`ClientGateway`'s capability probes — `supportsElicitation()`, `supportsSampling()`, `supportsRoots()` and -the sub-capability variants — read the same declaration, so they work in both eras. - -### Mirroring a tool argument into a header - -A tool parameter annotated with `x-mcp-header` is mirrored into `Mcp-Param-{Name}` by the client, and the -server checks that the two agree: - -```php -->addTool( - static fn (string $region, string $query): string => …, - name: 'execute_sql', - inputSchema: [ - 'type' => 'object', - 'properties' => [ - 'region' => ['type' => 'string', 'x-mcp-header' => 'Region'], - 'query' => ['type' => 'string'], - ], - 'required' => ['region', 'query'], - ], -) -``` - -The annotation must name a valid HTTP field, be unique case-insensitively, and sit on a `string`, `integer` -or `boolean` property reachable through `properties` keys alone. `Tool` refuses a definition that breaks -any of those rather than letting it fail later as a header mismatch. - -## Multi round-trip requests - -There are no server-initiated requests in this revision. A server that needs sampling, elicitation or roots -**returns** the ask, and the client retries the original call carrying the answers. - -This is the shape to write handlers in even if you also serve handshake-era clients — the SDK fulfils the -same ask over their connection instead. See [What a handler forks on](#what-a-handler-forks-on). - -```php -use Mcp\Schema\Result\CallToolResult; -use Mcp\Schema\Result\InputRequiredResult; - -static function (RequestContext $context): CallToolResult|InputRequiredResult { - $answer = $context->getInputContext()?->elicitResult('who'); - - if (null === $answer) { - return new InputRequiredResult( - ['who' => new ElicitRequest('Your name?', $schema)], - requestState: $context->mintRequestState(['asked' => 'who']), - ); - } - - return new CallToolResult([new TextContent("Hello, {$answer->content['name']}!")]); -} -``` - -`tools/call`, `prompts/get` and `resources/read` may answer this way; nothing else may. - -**Reading the answers.** `InputContext` hands them back typed — `elicitResult()`, `samplingResult()`, -`rootsResult()` — and returns `null` for an answer that is absent *or* malformed. Both mean the same thing -to a handler: ask again. `response()` is still there for the raw array. - -**`requestState`.** Whatever the server needs to remember between rounds. It travels through the client, so -it is attacker-controlled on return; `mintRequestState()` seals it with an HMAC and a TTL, and a state that -fails verification never reaches a handler. Configure the key with `Builder::setRequestState()`: - -```php -->setRequestState($_ENV['MCP_REQUEST_STATE_KEY'], ttl: 600) -``` - -The **same key must reach every process that might serve the retry**. A per-process random value works only -for a single-process deployment. Nothing secret belongs in the payload — it is signed, not encrypted. - -**Capabilities.** A server must not ask for input the client cannot provide. The SDK checks each ask against -the request's declared capabilities and answers `-32021` — with the missing set in -`data.requiredCapabilities` — rather than sending an ask that could never be answered. Url-mode elicitation -needs its own `elicitation.url` declaration; a bare `elicitation` means form mode only. - -**What not to call.** `ClientGateway::sample()`, `elicit()`, `elicitUrl()` and `listRoots()` belong to the -handshake era. Calling one under this revision raises a `LogicException` naming `InputRequiredResult` as the -replacement. - -## Progress and logging - -Both travel on the request's own response stream, and both are opt-in by the client: - -- **Progress** — the client sends `_meta.progressToken`; without one, `$gateway->progress()` sends nothing. -- **Logging** — the client sends `_meta["io.modelcontextprotocol/logLevel"]`; without it the server **must - not** emit `notifications/message` at all, and does not. - -```php -static function (RequestContext $context): string { - $client = $context->getClientGateway(); - $client->log(LoggingLevel::Info, 'Reindexing shard 1 of 3'); - $client->progress(1, 3, 'Shard 1 of 3'); - - return 'done'; -} -``` - -The server answers with a single JSON object when the handler emits nothing, and opens an SSE stream when it -does — so an error that has to carry a specific status still gets one, and a handler that talks gets a -stream. Trace context from the request is echoed onto every notification it causes. - -## Caching - -`server/discover`, the four list methods and `resources/read` **must** carry `ttlMs` and `cacheScope`. The -default is `ttlMs: 0, cacheScope: "private"` — conformant, and a flat refusal to let anything be cached. -Say what you actually mean: - -```php -use Mcp\Schema\Enum\CacheScope; -use Mcp\Server\Wire\CachePolicy; - -->setCachePolicy( - CachePolicy::default(30_000) - ->withMethod('tools/list', 3_600_000, CacheScope::Public) - ->withMethod('server/discover', 3_600_000, CacheScope::Public), -) -``` - -`public` lets a shared proxy serve one caller's answer to another, so use it only for results that do not -vary by caller. A `ReadResourceResult` may set its own `ttlMs`/`cacheScope`, which win over the policy. -Results produced by an MRTR retry are never given hints: their inputs are not part of any cache key. - -## Subscriptions - -`subscriptions/listen` replaces the HTTP `GET` stream and `resources/subscribe`. The client opens a -long-lived POST whose response stream carries the notification types it asked for; the server acknowledges -first with `notifications/subscriptions/acknowledged`, reporting the subset it agreed to honour. - -Delivery needs a bus, because the process that publishes and the process holding the stream open are often -not the same one: - -```php -use Mcp\Server\Subscription\InMemoryNotificationBus; -use Mcp\Server\Subscription\Psr16NotificationBus; - -// stdio, or a persistent runtime where the whole server is one process -->setNotificationBus(new InMemoryNotificationBus()) - -// PHP-FPM: the publisher and the stream are different workers -->setNotificationBus(new Psr16NotificationBus($cache)) -``` - -Registry changes (`registerTool()`, `unregisterPrompt()`, …) are published automatically. Anything else — -`notifications/resources/updated` above all — is published by the application: - -```php -$bus->publish(new ResourceUpdatedNotification('file:///project/config.json')); -``` - -`Builder::setSubscriptionLifetime()` bounds how long a stream is held before the server closes it -gracefully. The real ceiling is the runtime's: under PHP-FPM a stream cannot outlive `max_execution_time`. -Pass `0` for "until the client or the runtime ends it". - -## Serving both eras - -One endpoint serves both, and the client picks nothing. Every request is classified once, before anything -else looks at it, and routed to the lifecycle it belongs to. The decision is **body-primary**: - -| Evidence | Routed to | -| --- | --- | -| `params._meta` names a modern revision | modern era | -| `params._meta` names a handshake revision | handshake era | -| no such member | handshake era — `initialize` included | -| a notification with no member, under a modern header | modern era | -| `GET` / `DELETE` | handshake era | - -The `MCP-Protocol-Version` header never decides. It is cross-checked against the body, and a request whose -header contradicts its `_meta` is refused with `-32020` before either leg sees it — the check has to happen -at the edge, because a body claiming a handshake revision routes to a leg that has no such check of its -own. A modern header on a request carrying no envelope is refused with `-32602` naming the member it wants. - -An unrecognised revision goes to whichever leg can answer it best: claimed in the envelope, the modern leg -answers, naming the modern revisions it serves; named only in a header, the handshake leg answers, naming -the handshake ones. - -Both legs come from **one** builder configuration — one registry, one set of handler instances, one session -manager. A tool registered once is reachable from both, and a change made through one is visible to the -other. - -To serve the handshake era alone, say so: - -```php -$server = Server::builder() - ->setServerInfo('My Server', '1.0.0') - ->withoutModernEra() - ->build(); -``` - -That server refuses a modern claim with `-32022`, naming the handshake revisions it does serve. -`setModernVersions()` narrows the modern leg instead of removing it. - -For the opposite — an endpoint that serves the modern era and nothing else — build the dispatcher on its -own and mount it on `StatelessHttpTransport`: - -```php -$protocol = Server::builder() - ->setServerInfo('My Server', '1.0.0') - ->buildStateless([ProtocolVersion::V2026_07_28]); - -(new SapiEmitter())->emit((new StatelessHttpTransport($protocol))->handle($request)); -``` - -### What a handler forks on - -Nothing. Tools, resources, prompts, structured output, progress and errors do not care which era called, -and neither does the one thing that looks like it should: **asking the user something**. - -Write it the 2026-07-28 way — return an `InputRequiredResult` naming what you need, read the answer off -`RequestContext::getInputContext()` when the call comes back. On a handshake-era connection the SDK's -input-required shim fulfils the same ask over that connection's own channel: each embedded request goes -out as the real `elicitation/create` / `sampling/createMessage` / `roots/list`, and the handler is -re-entered with the answers under the keys it asked for. It is on by default; -[`examples/server/elicitation`](../examples/server/elicitation) and -[`examples/server/client-communication`](../examples/server/client-communication) are written this way and -name no era anywhere. - -Two things to know about it. - -**Re-entry is re-execution.** The handler runs again from the top each round, so it has to re-derive where -it is from what came back rather than from anything it kept. That is already true of the modern era — the -client retries the whole call there — so a portable handler is written that way regardless. It is only new -if you were relying on `ClientGateway::elicit()` suspending mid-body and keeping your locals; that keeps -working untouched, since nothing here runs unless a handler *returns* an ask. - -**Each round holds the request open.** The shim waits for the client's answer inside the originating -request, which on a process-per-request runtime means it holds a worker for as long as the user takes. -That is the same cost `ClientGateway::elicit()` already pays on that leg, but the shim makes it reachable -from handlers that never mention it — so size `setInputRequiredLimits()` against your pool. - -```php -$server = Server::builder() - ->setServerInfo('My Server', '1.0.0') - // Re-entries per request, and seconds to wait for one answer. - ->setInputRequiredLimits(maxRounds: 4, roundTimeout: 120) - ->build(); -``` - -`withoutInputRequiredShim()` turns it off, so such a handler fails on a handshake-era connection instead of -being fulfilled behind your back. - -## Writing a client for this revision - -One line selects the lifecycle; nothing else about the API changes. - -```php -$client = Client::builder() - ->setClientInfo('my-client', '1.0.0') - ->setProtocolVersion(ProtocolVersion::V2026_07_28) - ->setCapabilities(new ClientCapabilities(elicitation: true)) - ->addRequestHandler($myElicitationHandler) - ->build(); - -$client->connect(new HttpTransport('https://example.com/mcp')); - -$client->callTool('greet', []); -``` - -What that changes underneath: - -- **No handshake.** `connect()` sends no `initialize`. It asks `server/discover` only for the server's - identity, and a server that does not answer it still yields a usable connection — the method is - optional. If discovery *does* report `supportedVersions` and the configured revision is not among - them, the client moves to a modern revision the server lists, or refuses the connection outright - rather than talking past it. -- **An envelope on every request**, carrying the revision, the declared capabilities and the client - identity. The capabilities are what let a server decide, per request, whether it may ask for input. -- **Headers on every POST** — `MCP-Protocol-Version`, `Mcp-Method`, and `Mcp-Name` where the method - addresses a subject. Arguments annotated with `x-mcp-header` are mirrored into `Mcp-Param-*`, which - requires the client to have listed the tool first; `tools/list` is what populates that knowledge. - A tool whose annotations are malformed is dropped from the listing and refused if called, since the - client cannot produce the headers it demands. -- **Multi round-trip calls are answered by the client.** A result of `resultType: "input_required"` is - resolved through the same request handlers that served server-initiated requests in the handshake era, - and the call is re-sent with `inputResponses` and the server's `requestState` echoed back byte for - byte, under a new JSON-RPC id. The caller sees one call and one result. - -Headers are an HTTP concern, so a transport opts into them by implementing `HeaderAwareTransportInterface`; -`HttpTransport` does, `StdioTransport` has nothing to carry them on. Everything else — the envelope, the -skipped handshake, the round-trip loop — applies to both. - -See `examples/client/stateless_lifecycle_client.php` for a runnable version. - -## What was removed - -Answered with `404` and `-32601` by a modern server: - -- `initialize`, `notifications/initialized` -- `ping` -- `logging/setLevel` — replaced by `_meta["io.modelcontextprotocol/logLevel"]` -- `resources/subscribe`, `resources/unsubscribe` — replaced by the `resourceSubscriptions` filter of - `subscriptions/listen` -- `notifications/roots/list_changed` - -Also gone: `Mcp-Session-Id`, the HTTP `GET` stream, and SSE resumability (`Last-Event-ID`). A broken -response stream loses the request; the client re-issues it with a new id. - -Error code `-32002` (resource not found) is retired in favour of `-32602`, and must not be emitted by a -server of this revision. The SDK picks the code from the revision serving the request, so a handshake-era -client still gets `-32002`. - -Roots, sampling and logging are all **deprecated** as of this revision. They remain functional for at least -twelve months; new servers should pass directories through tool arguments or resource URIs instead of roots, -integrate with an LLM provider directly instead of sampling, and log to `stderr` or OpenTelemetry instead of -`notifications/message`. diff --git a/mkdocs.yml b/mkdocs.yml index 47a57cc8..4bf405da 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -44,6 +44,14 @@ nav: - "Tools, resources & prompts": client/capabilities.md - Server-initiated requests: client/server-requests.md - Error handling: client/errors.md + - The 2026-07-28 lifecycle: + - lifecycle/index.md + - What travels with a request: lifecycle/requests.md + - Asking for input: lifecycle/input-required.md + - Caching: lifecycle/caching.md + - Subscriptions: lifecycle/subscriptions.md + - Serving both eras: lifecycle/serving-both-eras.md + - Clients on this revision: lifecycle/client.md - Advanced: - advanced/index.md - Events: advanced/events.md From 0fb53276f120c25b2d11a7711bf3c90b0fd6ad21 Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Wed, 19 Aug 2026 23:25:21 +0200 Subject: [PATCH 08/13] [Docs] Link the Inspector to its documentation, not its repo --- docs/get-started/inspector.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/get-started/inspector.md b/docs/get-started/inspector.md index 86650e8f..0a3ea07c 100644 --- a/docs/get-started/inspector.md +++ b/docs/get-started/inspector.md @@ -1,6 +1,6 @@ # Try it with the Inspector -The [MCP Inspector](https://github.com/modelcontextprotocol/inspector) is an interactive +The [MCP Inspector](https://modelcontextprotocol.io/docs/latest/tools/inspector) is an interactive UI for poking at a server: it lists what the server exposes and lets you call it by hand. It is the fastest way to see whether your server does what you think it does. From 04961fb0f8fe5a420bcd497a93eccc7b99ff363e Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Wed, 19 Aug 2026 23:34:35 +0200 Subject: [PATCH 09/13] [Docs] Distribute the 2026-07-28 material into the task sections The Python and TypeScript SDKs file each feature where the task lives and keep only an era comparison on its own page. Follow them: input-required moves next to the other handler concerns, caching, subscriptions and era routing to "Running your server", and one Protocol versions page carries the rest. --- README.md | 2 +- docs/client/connecting.md | 6 +- docs/client/index.md | 2 +- docs/examples.md | 12 +- docs/handlers/client-communication.md | 27 +++- docs/handlers/index.md | 6 +- .../{lifecycle => handlers}/input-required.md | 16 ++- docs/handlers/logging.md | 2 +- docs/index.md | 4 +- docs/lifecycle/client.md | 47 ------ docs/lifecycle/index.md | 94 ------------ docs/lifecycle/requests.md | 97 ------------- docs/lifecycle/serving-both-eras.md | 62 -------- docs/protocol-versions.md | 122 ++++++++++++++++ docs/{lifecycle => run}/caching.md | 5 +- docs/run/http.md | 4 +- docs/run/index.md | 10 +- docs/run/protocol-eras.md | 136 ++++++++++++++++++ docs/run/server-builder.md | 8 +- docs/run/sessions.md | 2 +- docs/{lifecycle => run}/subscriptions.md | 4 + mkdocs.yml | 13 +- 22 files changed, 335 insertions(+), 346 deletions(-) rename docs/{lifecycle => handlers}/input-required.md (85%) delete mode 100644 docs/lifecycle/client.md delete mode 100644 docs/lifecycle/index.md delete mode 100644 docs/lifecycle/requests.md delete mode 100644 docs/lifecycle/serving-both-eras.md create mode 100644 docs/protocol-versions.md rename docs/{lifecycle => run}/caching.md (74%) create mode 100644 docs/run/protocol-eras.md rename docs/{lifecycle => run}/subscriptions.md (85%) diff --git a/README.md b/README.md index 7372fcdd..cd8d311a 100644 --- a/README.md +++ b/README.md @@ -297,7 +297,7 @@ The full documentation is published at **[php.sdk.modelcontextprotocol.io](https - **[Inside your handler](docs/handlers/index.md)** — Sampling, logging, progress, and notifications from within a handler - **[Running your server](docs/run/index.md)** — Server builder, STDIO and HTTP transports, framework integration, sessions, authorization - **[Clients](docs/client/index.md)** — Client SDK for connecting to and communicating with MCP servers -- **[The 2026-07-28 lifecycle](docs/lifecycle/index.md)** — The stateless protocol revision: per-request metadata, `server/discover`, multi round-trip requests, caching and subscriptions +- **[Protocol versions](docs/protocol-versions.md)** — The two protocol eras, and what revision `2026-07-28` changed - **[Advanced](docs/advanced/index.md)** — Events, protocol extensions (including MCP Apps), and custom message handlers - **[API Reference](https://php.sdk.modelcontextprotocol.io/api/)** — Generated class reference diff --git a/docs/client/connecting.md b/docs/client/connecting.md index 1d22febe..41887f0c 100644 --- a/docs/client/connecting.md +++ b/docs/client/connecting.md @@ -4,7 +4,7 @@ A client is configured once through its builder, then connected to a [transport](transports.md). Connecting performs the MCP initialization handshake, after which the server's capabilities are known and its elements can be used. On protocol revision `2026-07-28` there is no handshake to perform — see -[Clients on this revision](../lifecycle/client.md). +[Clients on this revision](../protocol-versions.md). ## Client Builder @@ -80,7 +80,7 @@ on. Use `$client->getProtocolVersion()` after connecting to read what was actual Setting a modern revision such as `2026-07-28` selects the other lifecycle rather than making an offer: there is no `initialize` to negotiate with, so `connect()` sends none and every request carries its own revision instead. Nothing -else about the client API changes. See [Clients on this revision](../lifecycle/client.md) for what happens underneath. +else about the client API changes. See [Clients on this revision](../protocol-versions.md) for what happens underneath. See [Protocol Version Negotiation](../run/server-builder.md#protocol-version-negotiation) for the server side of the exchange. @@ -122,7 +122,7 @@ $client = Client::builder() ### Request Handlers Register handlers for server-initiated requests (e.g., sampling). The same handlers answer a -[multi round-trip](../lifecycle/input-required.md) `input_required` result on a modern revision, where the server +[multi round-trip](../handlers/input-required.md) `input_required` result on a modern revision, where the server returns its ask instead of sending a request: ```php diff --git a/docs/client/index.md b/docs/client/index.md index bdd62356..f2a9848f 100644 --- a/docs/client/index.md +++ b/docs/client/index.md @@ -37,5 +37,5 @@ $client->disconnect(); messages, sampling requests, and elicitations the server sends *you*. * **[Error handling](errors.md)** — which exception means what, plus a complete end-to-end example. -* **[Clients on this revision](../lifecycle/client.md)** — the one builder line that speaks +* **[Clients on this revision](../protocol-versions.md)** — the one builder line that speaks protocol revision `2026-07-28`, and what it changes underneath. diff --git a/docs/examples.md b/docs/examples.md index 7902d581..1830aa14 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -164,7 +164,7 @@ $server = Server::builder() - Server initiated communication back to the client - Logging, sampling, progress and notifications - Using `ClientGateway` in tool method via method argument injection of `RequestContext` -- Sampling and roots asked for the [multi round-trip](lifecycle/input-required.md) way, so the +- Sampling and roots asked for the [multi round-trip](handlers/input-required.md) way, so the same tools serve a handshake-era and a `2026-07-28` client without naming either ### Discovery User Profile @@ -285,7 +285,7 @@ public function formatText( **What it demonstrates:** - Asking the user for input from a tool, written the - [multi round-trip](lifecycle/input-required.md) way so one handler serves both protocol eras + [multi round-trip](handlers/input-required.md) way so one handler serves both protocol eras - Interactive user input during tool execution - Multi-field form schemas with validation - Boolean confirmation dialogs @@ -387,7 +387,7 @@ protocol-level sessions. It is HTTP-only and cannot be driven by the Inspector, **What it demonstrates:** - A tool answered in a single POST, with no handshake before it -- A [multi round-trip](lifecycle/input-required.md) tool that returns its ask and reads the answer +- A [multi round-trip](handlers/input-required.md) tool that returns its ask and reads the answer off the retry - Progress and log notifications travelling on the request's own response stream - Cache hints on `server/discover` and the list methods @@ -411,7 +411,7 @@ curl -sS http://127.0.0.1:8000/ \ ``` `tests/Integration/StatelessLifecycleTest.php` drives this example end to end. See -[The 2026-07-28 lifecycle](lifecycle/index.md) for the guide. +[The 2026-07-28 lifecycle](protocol-versions.md) for the guide. ## Client Examples @@ -670,7 +670,7 @@ php examples/client/stdio_roots.php **What it demonstrates:** - Selecting protocol revision `2026-07-28` with a single `setProtocolVersion()` call - A connection that sends no `initialize`, and asks `server/discover` only for the server's identity -- A [multi round-trip](lifecycle/input-required.md) call answered by the client, so the caller sees +- A [multi round-trip](handlers/input-required.md) call answered by the client, so the caller sees one call and one result **Key Features:** @@ -704,4 +704,4 @@ php -S 127.0.0.1:8000 examples/server/stateless-lifecycle/server.php php examples/client/stateless_lifecycle_client.php ``` -See [Clients on this revision](lifecycle/client.md) for what that one builder line changes. +See [Clients on this revision](protocol-versions.md) for what that one builder line changes. diff --git a/docs/handlers/client-communication.md b/docs/handlers/client-communication.md index 35fde443..578d31ed 100644 --- a/docs/handlers/client-communication.md +++ b/docs/handlers/client-communication.md @@ -9,7 +9,7 @@ request-response flow. > `ClientGateway::sample()`, `elicit()` and `listRoots()` raise a `LogicException` there. > Logging and progress still work as described below — they simply travel on the request's own > response stream, and the client opts into each. See -> [Asking for input](../lifecycle/input-required.md). +> [Asking for input](input-required.md). ## ClientGateway @@ -33,8 +33,10 @@ class MyService $context->getClientGateway()->log(...); ``` -The same object also carries the protocol revision negotiated for the current request, which is useful when a feature is -only available from a certain revision on: +## Request metadata + +`RequestContext` also carries what the current request said about itself, which is useful when a feature is only +available from a certain revision on: ```php use Mcp\Schema\Enum\ProtocolVersion; @@ -44,6 +46,22 @@ if ($context->getProtocolVersion()->isAtLeast(ProtocolVersion::V2026_07_28)) { } ``` +Two more accessors read what the client declared, and both work in either +[protocol era](../protocol-versions.md) — negotiated once during the handshake, or declared per request from +`2026-07-28` on: + +```php +$context->getClientCapabilities(); // what this client declared, or null in the handshake era +$context->getTraceContext(); // traceparent / tracestate / baggage, verbatim +``` + +`ClientGateway`'s capability probes — `supportsElicitation()`, `supportsSampling()`, `supportsRoots()` and the +sub-capability variants — read the same declaration. + +W3C trace context is passed through exactly as it arrived, and echoed onto every notification the request causes, +so a span stays joined across the response stream. Reading it adds no OpenTelemetry dependency — the values are +strings. + ## Sampling > **Deprecated** since protocol revision `2026-07-28` ([SEP-2577](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577)), earliest removal `2027-07-28`. Sampling keeps working until then; new integrations should call an LLM provider's API directly instead. @@ -115,6 +133,9 @@ notification a server can update a client while an operation is ongoing: $clientGateway->progress(4.2, 10, 'Downloading needed images.'); ``` +Progress is opt-in by the client in both eras: it sends a `progressToken` with the request, and without one this +call sends nothing. + ## Notification Lastly, the server can push all kind of notifications, that extend the abstract `Mcp\Schema\JsonRpc\Notification` class diff --git a/docs/handlers/index.md b/docs/handlers/index.md index 11c70f6d..6435287f 100644 --- a/docs/handlers/index.md +++ b/docs/handlers/index.md @@ -26,9 +26,9 @@ public function summarize(string $text, RequestContext $context): string call, and sending notifications. * **[Logging](logging.md)** — structured PSR-3 log messages that surface in the client, not in your server's log file. -* **[Asking for input](../lifecycle/input-required.md)** — returning an `InputRequiredResult` - when a handler needs elicitation, sampling or roots. Written that way, one handler serves - both protocol eras. +* **[Asking for input](input-required.md)** — returning an `InputRequiredResult` when a + handler needs elicitation, sampling or roots. Written that way, one handler serves both + [protocol eras](../protocol-versions.md). Handlers that need application services (a database connection, an API client) get them from the container instead; see diff --git a/docs/lifecycle/input-required.md b/docs/handlers/input-required.md similarity index 85% rename from docs/lifecycle/input-required.md rename to docs/handlers/input-required.md index 7cdce8cb..9cb3dd5e 100644 --- a/docs/lifecycle/input-required.md +++ b/docs/handlers/input-required.md @@ -1,11 +1,15 @@ # Asking for input -There are no server-initiated requests in this revision. A server that needs sampling, -elicitation or roots **returns** the ask, and the client retries the original call carrying -the answers. The specification calls this a multi round-trip request (MRTR). - -This is the shape to write handlers in even if you also serve handshake-era clients — the -SDK fulfils the same ask over their connection instead. See +Some handlers cannot finish in one go: they need the user to confirm something, fill in a +form, name a directory, or have the client's model draft a paragraph. The way to write that +is to **return** the ask — an `InputRequiredResult` naming what you need — and read the +answer off `RequestContext` when the call comes back. + +Write it that way once and it serves both [protocol eras](../protocol-versions.md). +Revision `2026-07-28` has no server-initiated requests at all, so the client retries the +original call carrying the answers; the specification calls that a multi round-trip request +(MRTR). On a handshake-era connection the SDK fulfils the same ask over that connection's own +channel instead. Your handler does not fork on which — see [What a handler forks on](#what-a-handler-forks-on). ```php diff --git a/docs/handlers/logging.md b/docs/handlers/logging.md index 43f3bea4..1e61448c 100644 --- a/docs/handlers/logging.md +++ b/docs/handlers/logging.md @@ -7,7 +7,7 @@ Level **warning** is the default level, so anything below it is dropped until th `logging/setLevel`. !!! note - Under the [2026-07-28 lifecycle](../lifecycle/requests.md#progress-and-logging) there is no + Under the [2026-07-28 lifecycle](../run/protocol-eras.md#what-a-modern-request-carries) there is no `logging/setLevel`: the client names its level in each request's `_meta["io.modelcontextprotocol/logLevel"]`, and a request naming none receives no log notifications at all. diff --git a/docs/index.md b/docs/index.md index 341e635d..cc9c375a 100644 --- a/docs/index.md +++ b/docs/index.md @@ -95,8 +95,8 @@ around in. **[Inside your handler](handlers/index.md)**. * Building the other side, an application that *uses* MCP servers, is **[Clients](client/index.md)**. -* The stateless protocol revision — no handshake, no sessions — is - **[The 2026-07-28 lifecycle](lifecycle/index.md)**. +* The two protocol eras, and what revision `2026-07-28` changed, are + **[Protocol versions](protocol-versions.md)**. * Complete, runnable projects are in **[Examples](examples.md)**. * Hunting for an exact signature? The **[API Reference](https://php.sdk.modelcontextprotocol.io/api/)** is generated from the source. diff --git a/docs/lifecycle/client.md b/docs/lifecycle/client.md deleted file mode 100644 index 68aa7ac6..00000000 --- a/docs/lifecycle/client.md +++ /dev/null @@ -1,47 +0,0 @@ -# Clients on this revision - -One line selects the lifecycle; nothing else about the [client API](../client/index.md) -changes. - -```php -$client = Client::builder() - ->setClientInfo('my-client', '1.0.0') - ->setProtocolVersion(ProtocolVersion::V2026_07_28) - ->setCapabilities(new ClientCapabilities(elicitation: true)) - ->addRequestHandler($myElicitationHandler) - ->build(); - -$client->connect(new HttpTransport('https://example.com/mcp')); - -$client->callTool('greet', []); -``` - -What that changes underneath: - -- **No handshake.** `connect()` sends no `initialize`. It asks `server/discover` only for the - server's identity, and a server that does not answer it still yields a usable connection — - the method is optional. If discovery *does* report `supportedVersions` and the configured - revision is not among them, the client moves to a modern revision the server lists, or - refuses the connection outright rather than talking past it. -- **An envelope on every request**, carrying the revision, the declared capabilities and the - client identity. The capabilities are what let a server decide, per request, whether it may - ask for input. -- **Headers on every POST** — `MCP-Protocol-Version`, `Mcp-Method`, and `Mcp-Name` where the - method addresses a subject. Arguments annotated with `x-mcp-header` are mirrored into - `Mcp-Param-*`, which requires the client to have listed the tool first; `tools/list` is what - populates that knowledge. A tool whose annotations are malformed is dropped from the listing - and refused if called, since the client cannot produce the headers it demands. -- **[Multi round-trip calls](input-required.md) are answered by the client.** A result of - `resultType: "input_required"` is resolved through the same - [request handlers](../client/server-requests.md) that served server-initiated requests in the - handshake era, and the call is re-sent with `inputResponses` and the server's `requestState` - echoed back byte for byte, under a new JSON-RPC id. The caller sees one call and one result. - -Headers are an HTTP concern, so a transport opts into them by implementing -`HeaderAwareTransportInterface`; `HttpTransport` does, `StdioTransport` has nothing to carry -them on. Everything else — the envelope, the skipped handshake, the round-trip loop — applies -to both. - -See -[`examples/client/stateless_lifecycle_client.php`](https://github.com/modelcontextprotocol/php-sdk/blob/main/examples/client/stateless_lifecycle_client.php) -for a runnable version, described in [Examples](../examples.md#modern-era-client). diff --git a/docs/lifecycle/index.md b/docs/lifecycle/index.md deleted file mode 100644 index 35407bf4..00000000 --- a/docs/lifecycle/index.md +++ /dev/null @@ -1,94 +0,0 @@ -# The 2026-07-28 lifecycle - -Protocol revision `2026-07-28` removed the `initialize` handshake and protocol-level -sessions. Everything a server needs to answer a request now travels *in* that request, -which means any process can answer any request and none of them need to share state. - -Tools, resources, prompts and their handlers are unaffected — the same registrations -serve either lifecycle. What changes is the wire around them, and that is what this -section covers. - -## The two eras - -| | Handshake era (`2025-11-25` and earlier) | Modern era (`2026-07-28`) | -| --- | --- | --- | -| Opening | `initialize` / `notifications/initialized` | none | -| Version | negotiated once, kept on the session | declared on every request | -| Capabilities | exchanged once | declared on every request | -| Discovery | `initialize` result | `server/discover` | -| Sessions | `Mcp-Session-Id` | removed | -| Server → client requests | sent as JSON-RPC requests | returned in the result (MRTR) | -| Change notifications | HTTP `GET` stream, `resources/subscribe` | `subscriptions/listen` | -| Dispatcher | `Protocol` | `StatelessProtocol` | -| HTTP entry | `StreamableHttpTransport` — the same one, for both | - -`ProtocolVersion::isModern()` tells the two apart, and -`Mcp\Schema\Enum\ProtocolVersion::FIRST_MODERN_VERSION` is where the boundary sits. - -## Building a stateless server - -There is nothing to build differently. `Builder::build()` produces a `Server` carrying a -dispatcher for each era, and `StreamableHttpTransport` decides per request which of them -answers: - -```php -use Mcp\Server; -use Mcp\Server\Transport\StreamableHttpTransport; - -$server = Server::builder() - ->setServerInfo('My Server', '1.0.0') - ->addTool(static fn (string $city): string => "17°C in {$city}", name: 'get_weather', description: '…') - ->build(); - -(new SapiEmitter())->emit($server->run(new StreamableHttpTransport($request))); -``` - -That one endpoint answers `initialize` and `server/discover` alike. See -[Serving both eras](serving-both-eras.md) for how the decision is made and how to opt out -of it. - -Modern-era requests accept `POST` only; a `GET` or `DELETE` is a handshake-era session -operation and is routed as one. - -A full example lives in -[`examples/server/stateless-lifecycle/server.php`](https://github.com/modelcontextprotocol/php-sdk/tree/main/examples/server/stateless-lifecycle), -and [Examples](../examples.md#the-2026-07-28-lifecycle) walks through it. - -## The rest of this section - -* **[What travels with a request](requests.md)** — the `_meta` envelope every request - must carry, the headers that mirror it, and how progress and logging become opt-in. -* **[Asking for input](input-required.md)** — multi round-trip requests: how a handler - asks for elicitation, sampling or roots when it cannot interrupt the call to do so. -* **[Caching](caching.md)** — the `ttlMs` / `cacheScope` hints a cacheable result must - carry, and how to say what you actually mean by them. -* **[Subscriptions](subscriptions.md)** — `subscriptions/listen` and the notification bus - that makes delivery work across processes. -* **[Serving both eras](serving-both-eras.md)** — how one endpoint classifies and routes - each request, and how to serve only one era. -* **[Clients on this revision](client.md)** — the one builder line that selects it, and - what it changes underneath. - -## What was removed - -Answered with `404` and `-32601` by a modern server: - -- `initialize`, `notifications/initialized` -- `ping` -- `logging/setLevel` — replaced by `_meta["io.modelcontextprotocol/logLevel"]` -- `resources/subscribe`, `resources/unsubscribe` — replaced by the `resourceSubscriptions` - filter of `subscriptions/listen` -- `notifications/roots/list_changed` - -Also gone: `Mcp-Session-Id`, the HTTP `GET` stream, and SSE resumability (`Last-Event-ID`). -A broken response stream loses the request; the client re-issues it with a new id. - -Error code `-32002` (resource not found) is retired in favour of `-32602`, and must not be -emitted by a server of this revision. The SDK picks the code from the revision serving the -request, so a handshake-era client still gets `-32002`. - -Roots, sampling and logging are all **deprecated** as of this revision. They remain -functional for at least twelve months; new servers should pass directories through tool -arguments or resource URIs instead of roots, integrate with an LLM provider directly -instead of sampling, and log to `stderr` or OpenTelemetry instead of -`notifications/message`. diff --git a/docs/lifecycle/requests.md b/docs/lifecycle/requests.md deleted file mode 100644 index b1de42a2..00000000 --- a/docs/lifecycle/requests.md +++ /dev/null @@ -1,97 +0,0 @@ -# What travels with a request - -There is no handshake to remember anything, so every request carries what the server needs -to answer it: the revision being spoken, what the client can be asked to do, and who is -asking. The HTTP layer mirrors some of that into headers so an intermediary can route -without parsing the body. - -## Per-request metadata - -Every request **must** carry two members in `params._meta`: - -| `_meta` key | Required | Header | -| --- | --- | --- | -| `io.modelcontextprotocol/protocolVersion` | yes | `MCP-Protocol-Version` | -| `io.modelcontextprotocol/clientCapabilities` | yes | — | -| `io.modelcontextprotocol/clientInfo` | no | — | -| `io.modelcontextprotocol/logLevel` | no | — | -| `progressToken` | no | — | -| `traceparent`, `tracestate`, `baggage` | no | — | - -Plus `Mcp-Method` on every request, and `Mcp-Name` on `tools/call`, `prompts/get` and -`resources/read`. A header that disagrees with the body is refused with `-32020`; a missing -required `_meta` member with `-32602`; an unsupported version with `-32022`, carrying the -supported set for the client to retry from. - -Handlers read the metadata through -[`RequestContext`](../handlers/index.md): - -```php -$context->getProtocolVersion(); // the revision serving this request -$context->getClientCapabilities(); // what this client declared, or null in the handshake era -$context->getTraceContext(); // traceparent / tracestate / baggage, verbatim -``` - -`ClientGateway`'s capability probes — `supportsElicitation()`, `supportsSampling()`, -`supportsRoots()` and the sub-capability variants — read the same declaration, so they work -in both eras. - -## Trace context - -`traceparent`, `tracestate` and `baggage` are passed through exactly as they arrived, and -echoed onto every notification the request causes, so a span stays joined across the -response stream. Reading them adds no OpenTelemetry dependency — they are strings: - -```php -['traceparent' => '00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01'] -``` - -## Mirroring a tool argument into a header - -A tool parameter annotated with `x-mcp-header` is mirrored into `Mcp-Param-{Name}` by the -client, and the server checks that the two agree: - -```php -->addTool( - static fn (string $region, string $query): string => …, - name: 'execute_sql', - inputSchema: [ - 'type' => 'object', - 'properties' => [ - 'region' => ['type' => 'string', 'x-mcp-header' => 'Region'], - 'query' => ['type' => 'string'], - ], - 'required' => ['region', 'query'], - ], -) -``` - -The annotation must name a valid HTTP field, be unique case-insensitively, and sit on a -`string`, `integer` or `boolean` property reachable through `properties` keys alone. `Tool` -refuses a definition that breaks any of those rather than letting it fail later as a header -mismatch. See [Schema generation](../servers/schemas.md) for where a hand-written -`inputSchema` fits. - -## Progress and logging - -Both travel on the request's own response stream, and both are opt-in by the client: - -- **Progress** — the client sends `_meta.progressToken`; without one, `$gateway->progress()` - sends nothing. -- **Logging** — the client sends `_meta["io.modelcontextprotocol/logLevel"]`; without it the - server **must not** emit `notifications/message` at all, and does not. This replaced the - `logging/setLevel` request the handshake era used. - -```php -static function (RequestContext $context): string { - $client = $context->getClientGateway(); - $client->log(LoggingLevel::Info, 'Reindexing shard 1 of 3'); - $client->progress(1, 3, 'Shard 1 of 3'); - - return 'done'; -} -``` - -The server answers with a single JSON object when the handler emits nothing, and opens an -SSE stream when it does — so an error that has to carry a specific status still gets one, -and a handler that talks gets a stream. diff --git a/docs/lifecycle/serving-both-eras.md b/docs/lifecycle/serving-both-eras.md deleted file mode 100644 index 1917cc2a..00000000 --- a/docs/lifecycle/serving-both-eras.md +++ /dev/null @@ -1,62 +0,0 @@ -# Serving both eras - -One endpoint serves both, and the client picks nothing. Every request is classified once, -before anything else looks at it, and routed to the lifecycle it belongs to. The decision is -**body-primary**: - -| Evidence | Routed to | -| --- | --- | -| `params._meta` names a modern revision | modern era | -| `params._meta` names a handshake revision | handshake era | -| no such member | handshake era — `initialize` included | -| a notification with no member, under a modern header | modern era | -| `GET` / `DELETE` | handshake era | - -The `MCP-Protocol-Version` header never decides. It is cross-checked against the body, and a -request whose header contradicts its `_meta` is refused with `-32020` before either leg sees -it — the check has to happen at the edge, because a body claiming a handshake revision routes -to a leg that has no such check of its own. A modern header on a request carrying no envelope -is refused with `-32602` naming the member it wants. - -An unrecognised revision goes to whichever leg can answer it best: claimed in the envelope, -the modern leg answers, naming the modern revisions it serves; named only in a header, the -handshake leg answers, naming the handshake ones. - -Both legs come from **one** builder configuration — one registry, one set of handler -instances, one session manager. A tool registered once is reachable from both, and a change -made through one is visible to the other. - -## Middleware - -The [default middleware stack](../run/http.md#default-middleware) runs at the edge, before -the request's era is known, because what it enforces is true of both. `ProtocolVersionMiddleware` -is not in that stack: the `MCP-Protocol-Version` header rule belongs to the handshake era, so -the transport applies it only to requests it classified as handshake-era traffic, and the -modern leg answers for its own revisions. It is available as -`StreamableHttpTransport::handshakeMiddleware()` and is applied whether or not you replace the -edge stack. - -## Serving one era only - -To serve the handshake era alone, say so: - -```php -$server = Server::builder() - ->setServerInfo('My Server', '1.0.0') - ->withoutModernEra() - ->build(); -``` - -That server refuses a modern claim with `-32022`, naming the handshake revisions it does -serve. `setModernVersions()` narrows the modern leg instead of removing it. - -For the opposite — an endpoint that serves the modern era and nothing else — build the -dispatcher on its own and mount it on `StatelessHttpTransport`: - -```php -$protocol = Server::builder() - ->setServerInfo('My Server', '1.0.0') - ->buildStateless([ProtocolVersion::V2026_07_28]); - -(new SapiEmitter())->emit((new StatelessHttpTransport($protocol))->handle($request)); -``` diff --git a/docs/protocol-versions.md b/docs/protocol-versions.md new file mode 100644 index 00000000..97a5248d --- /dev/null +++ b/docs/protocol-versions.md @@ -0,0 +1,122 @@ +# Protocol versions + +MCP has two eras. Everything up to `2025-11-25` opens with an `initialize` handshake and +keeps the negotiated revision on a session. Protocol revision `2026-07-28` removed both: +everything a server needs to answer a request travels *in* that request, so any process can +answer any request and none of them need to share state. + +The SDK serves both, and a server built the ordinary way answers either. This page is the +map; the mechanics live with the task they belong to. + +## The two eras + +| | Handshake era (`2025-11-25` and earlier) | Modern era (`2026-07-28`) | +| --- | --- | --- | +| Opening | `initialize` / `notifications/initialized` | none | +| Version | negotiated once, kept on the session | declared on every request | +| Capabilities | exchanged once | declared on every request | +| Discovery | `initialize` result | `server/discover` | +| Sessions | `Mcp-Session-Id` | removed | +| Server → client requests | sent as JSON-RPC requests | returned in the result (MRTR) | +| Change notifications | HTTP `GET` stream, `resources/subscribe` | `subscriptions/listen` | +| Dispatcher | `Protocol` | `StatelessProtocol` | +| HTTP entry | `StreamableHttpTransport` — the same one, for both | + +`ProtocolVersion::isModern()` tells the two apart, and +`Mcp\Schema\Enum\ProtocolVersion::FIRST_MODERN_VERSION` is where the boundary sits. How the +handshake era agrees on a revision is +[Protocol version negotiation](run/server-builder.md#protocol-version-negotiation); the +modern era negotiates nothing. + +## What changes, and where it is written down + +Tools, resources, prompts and their handlers are unaffected — the same registrations serve +either lifecycle. What changes: + +* **[Asking for input](handlers/input-required.md)** — a handler that needs elicitation, + sampling or roots *returns* the ask instead of calling out. Write handlers this way and + they serve both eras. +* **[Serving both eras](run/protocol-eras.md)** — what a modern request carries, how one + endpoint classifies and routes each request, and how to serve one era only. +* **[Caching](run/caching.md)** — the `ttlMs` / `cacheScope` hints a cacheable result must + carry. +* **[Subscriptions](run/subscriptions.md)** — `subscriptions/listen` and the notification bus + behind it. +* **[Sessions](run/sessions.md)** — handshake-era only; the modern era has none. +* Progress and logging become per-request opt-ins — see + [Talking back to the client](handlers/client-communication.md#progress) and + [Logging](handlers/logging.md). + +## Speaking it from a client + +One line selects the lifecycle; nothing else about the [client API](client/index.md) changes. + +```php +$client = Client::builder() + ->setClientInfo('my-client', '1.0.0') + ->setProtocolVersion(ProtocolVersion::V2026_07_28) + ->setCapabilities(new ClientCapabilities(elicitation: true)) + ->addRequestHandler($myElicitationHandler) + ->build(); + +$client->connect(new HttpTransport('https://example.com/mcp')); + +$client->callTool('greet', []); +``` + +What that changes underneath: + +- **No handshake.** `connect()` sends no `initialize`. It asks `server/discover` only for the + server's identity, and a server that does not answer it still yields a usable connection — + the method is optional. If discovery *does* report `supportedVersions` and the configured + revision is not among them, the client moves to a modern revision the server lists, or + refuses the connection outright rather than talking past it. +- **An envelope on every request**, carrying the revision, the declared capabilities and the + client identity. The capabilities are what let a server decide, per request, whether it may + ask for input. +- **Headers on every POST** — `MCP-Protocol-Version`, `Mcp-Method`, and `Mcp-Name` where the + method addresses a subject. Arguments annotated with `x-mcp-header` are mirrored into + `Mcp-Param-*`, which requires the client to have listed the tool first; `tools/list` is what + populates that knowledge. A tool whose annotations are malformed is dropped from the listing + and refused if called, since the client cannot produce the headers it demands. +- **[Multi round-trip calls](handlers/input-required.md) are answered by the client.** A result + of `resultType: "input_required"` is resolved through the same + [request handlers](client/server-requests.md) that served server-initiated requests in the + handshake era, and the call is re-sent with `inputResponses` and the server's `requestState` + echoed back byte for byte, under a new JSON-RPC id. The caller sees one call and one result. + +Headers are an HTTP concern, so a transport opts into them by implementing +`HeaderAwareTransportInterface`; `HttpTransport` does, `StdioTransport` has nothing to carry +them on. Everything else — the envelope, the skipped handshake, the round-trip loop — applies +to both. + +See +[`examples/client/stateless_lifecycle_client.php`](https://github.com/modelcontextprotocol/php-sdk/blob/main/examples/client/stateless_lifecycle_client.php) +for a runnable version, described in [Examples](examples.md#modern-era-client). + +## What was removed + +Answered with `404` and `-32601` by a modern server: + +- `initialize`, `notifications/initialized` +- `ping` +- `logging/setLevel` — replaced by `_meta["io.modelcontextprotocol/logLevel"]` +- `resources/subscribe`, `resources/unsubscribe` — replaced by the `resourceSubscriptions` + filter of `subscriptions/listen` +- `notifications/roots/list_changed` + +Also gone: `Mcp-Session-Id`, the HTTP `GET` stream, and SSE resumability (`Last-Event-ID`). +A broken response stream loses the request; the client re-issues it with a new id. + +Error code `-32002` (resource not found) is retired in favour of `-32602`, and must not be +emitted by a server of this revision. The SDK picks the code from the revision serving the +request, so a handshake-era client still gets `-32002`. + +## Deprecations + +Roots, sampling and logging are all **deprecated** as of this revision +([SEP-2577](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577)), earliest +removal `2027-07-28`. They remain functional until then; new servers should pass directories +through tool arguments or resource URIs instead of roots, integrate with an LLM provider +directly instead of sampling, and log to `stderr` or OpenTelemetry instead of +`notifications/message`. diff --git a/docs/lifecycle/caching.md b/docs/run/caching.md similarity index 74% rename from docs/lifecycle/caching.md rename to docs/run/caching.md index 51ae8057..777b66c7 100644 --- a/docs/lifecycle/caching.md +++ b/docs/run/caching.md @@ -1,5 +1,8 @@ # Caching +> Caching hints belong to protocol revision `2026-07-28`; see [Protocol versions](../protocol-versions.md). +> A handshake-era client is served by the same server and simply never sees them. + `server/discover`, the four list methods and `resources/read` **must** carry `ttlMs` and `cacheScope`. The default is `ttlMs: 0, cacheScope: "private"` — conformant, and a flat refusal to let anything be cached. Say what you actually mean: @@ -21,5 +24,5 @@ conservative default stands until you change it. A `ReadResourceResult` may set its own `ttlMs`/`cacheScope`, which win over the policy. -Results produced by an [MRTR retry](input-required.md) are never given hints: their inputs +Results produced by an [MRTR retry](../handlers/input-required.md) are never given hints: their inputs are not part of any cache key. diff --git a/docs/run/http.md b/docs/run/http.md index ada47366..04292b0c 100644 --- a/docs/run/http.md +++ b/docs/run/http.md @@ -73,7 +73,7 @@ both eras. `ProtocolVersionMiddleware` is not in that stack: the `MCP-Protocol-V to the handshake era, so the transport applies it only to requests it classified as handshake-era traffic, and the modern leg answers for its own revisions. It is available as `StreamableHttpTransport::handshakeMiddleware()` and is applied whether or not you replace the edge stack. -See [Serving both eras](../lifecycle/serving-both-eras.md). +See [Serving both eras](protocol-eras.md). ## CORS Configuration @@ -161,7 +161,7 @@ handshake has to pass that revision here as well. Modern revisions never reach it. A `2026-07-28` request declares its revision in `params._meta` rather than in a header, and is routed away from this check entirely — see -[Serving both eras](../lifecycle/serving-both-eras.md). +[Serving both eras](protocol-eras.md). ## Request Body Size Limit diff --git a/docs/run/index.md b/docs/run/index.md index 846c2e22..d3a64e77 100644 --- a/docs/run/index.md +++ b/docs/run/index.md @@ -31,9 +31,11 @@ The rest of this section: a Symfony, Laravel, or Slim application, or running it standalone. * **[Sessions](sessions.md)** — where per-client state lives, which matters as soon as you serve HTTP from more than one process. +* **[Serving both eras](protocol-eras.md)** — the same endpoint also answers protocol + revision `2026-07-28`, which has no handshake and no sessions; what a modern request + carries, and how each one is routed. +* **[Caching](caching.md)** — the `ttlMs` / `cacheScope` hints a modern-era result carries. +* **[Subscriptions](subscriptions.md)** — `subscriptions/listen` and the notification bus + behind it. * **[Authorization](authorization.md)** — validating OAuth 2 access tokens in front of the HTTP transport. - -The same server also answers protocol revision `2026-07-28`, which has no handshake and no -sessions. Nothing above changes for it; what does is -**[The 2026-07-28 lifecycle](../lifecycle/index.md)**. diff --git a/docs/run/protocol-eras.md b/docs/run/protocol-eras.md new file mode 100644 index 00000000..9c6cb28f --- /dev/null +++ b/docs/run/protocol-eras.md @@ -0,0 +1,136 @@ +# Serving both eras + +`Server::builder()->build()` produces a server carrying a dispatcher for each +[protocol era](../protocol-versions.md), and `StreamableHttpTransport` decides per request +which of them answers. There is nothing to configure: + +```php +use Mcp\Server; +use Mcp\Server\Transport\StreamableHttpTransport; + +$server = Server::builder() + ->setServerInfo('My Server', '1.0.0') + ->addTool(static fn (string $city): string => "17°C in {$city}", name: 'get_weather', description: '…') + ->build(); + +(new SapiEmitter())->emit($server->run(new StreamableHttpTransport($request))); +``` + +That one endpoint answers `initialize` and `server/discover` alike. Modern-era requests +accept `POST` only; a `GET` or `DELETE` is a handshake-era session operation and is routed as +one. + +A full example lives in +[`examples/server/stateless-lifecycle/server.php`](https://github.com/modelcontextprotocol/php-sdk/tree/main/examples/server/stateless-lifecycle), +described in [Examples](../examples.md#the-2026-07-28-lifecycle). + +## What a modern request carries + +There is no handshake to remember anything, so every request carries what the server needs to +answer it. Two members in `params._meta` are **required**, and the HTTP layer mirrors some of +them into headers so an intermediary can route without parsing the body: + +| `_meta` key | Required | Header | +| --- | --- | --- | +| `io.modelcontextprotocol/protocolVersion` | yes | `MCP-Protocol-Version` | +| `io.modelcontextprotocol/clientCapabilities` | yes | — | +| `io.modelcontextprotocol/clientInfo` | no | — | +| `io.modelcontextprotocol/logLevel` | no | — | +| `progressToken` | no | — | +| `traceparent`, `tracestate`, `baggage` | no | — | + +Plus `Mcp-Method` on every request, and `Mcp-Name` on `tools/call`, `prompts/get` and +`resources/read`. A header that disagrees with the body is refused with `-32020`; a missing +required `_meta` member with `-32602`; an unsupported version with `-32022`, carrying the +supported set for the client to retry from. + +What a handler can read off all this is +[Talking back to the client](../handlers/client-communication.md#request-metadata). + +### Mirroring a tool argument into a header + +A tool parameter annotated with `x-mcp-header` is mirrored into `Mcp-Param-{Name}` by the +client, and the server checks that the two agree: + +```php +->addTool( + static fn (string $region, string $query): string => …, + name: 'execute_sql', + inputSchema: [ + 'type' => 'object', + 'properties' => [ + 'region' => ['type' => 'string', 'x-mcp-header' => 'Region'], + 'query' => ['type' => 'string'], + ], + 'required' => ['region', 'query'], + ], +) +``` + +The annotation must name a valid HTTP field, be unique case-insensitively, and sit on a +`string`, `integer` or `boolean` property reachable through `properties` keys alone. `Tool` +refuses a definition that breaks any of those rather than letting it fail later as a header +mismatch. See [Schema generation](../servers/schemas.md) for where a hand-written +`inputSchema` fits. + +## How a request is routed + +Every request is classified once, before anything else looks at it. The decision is +**body-primary**: + +| Evidence | Routed to | +| --- | --- | +| `params._meta` names a modern revision | modern era | +| `params._meta` names a handshake revision | handshake era | +| no such member | handshake era — `initialize` included | +| a notification with no member, under a modern header | modern era | +| `GET` / `DELETE` | handshake era | + +The `MCP-Protocol-Version` header never decides. It is cross-checked against the body, and a +request whose header contradicts its `_meta` is refused with `-32020` before either leg sees +it — the check has to happen at the edge, because a body claiming a handshake revision routes +to a leg that has no such check of its own. A modern header on a request carrying no envelope +is refused with `-32602` naming the member it wants. + +An unrecognised revision goes to whichever leg can answer it best: claimed in the envelope, +the modern leg answers, naming the modern revisions it serves; named only in a header, the +handshake leg answers, naming the handshake ones. + +Both legs come from **one** builder configuration — one registry, one set of handler +instances, one session manager. A tool registered once is reachable from both, and a change +made through one is visible to the other. + +## Middleware + +The [default middleware stack](http.md#default-middleware) runs at the edge, before the +request's era is known, because what it enforces is true of both. `ProtocolVersionMiddleware` +is not in that stack: the `MCP-Protocol-Version` header rule belongs to the handshake era, so +the transport applies it only to requests it classified as handshake-era traffic, and the +modern leg answers for its own revisions. It is available as +`StreamableHttpTransport::handshakeMiddleware()` and is applied whether or not you replace the +edge stack. + +## Serving one era only + +To serve the handshake era alone, say so: + +```php +$server = Server::builder() + ->setServerInfo('My Server', '1.0.0') + ->withoutModernEra() + ->build(); +``` + +That server refuses a modern claim with `-32022`, naming the handshake revisions it does +serve. `setModernVersions()` narrows the modern leg instead of removing it. + +For the opposite — an endpoint that serves the modern era and nothing else — build the +dispatcher on its own and mount it on `StatelessHttpTransport`: + +```php +$protocol = Server::builder() + ->setServerInfo('My Server', '1.0.0') + ->buildStateless([ProtocolVersion::V2026_07_28]); + +(new SapiEmitter())->emit((new StatelessHttpTransport($protocol))->handle($request)); +``` diff --git a/docs/run/server-builder.md b/docs/run/server-builder.md index 89365eba..e6f4d6b8 100644 --- a/docs/run/server-builder.md +++ b/docs/run/server-builder.md @@ -95,8 +95,8 @@ $server = Server::builder() This section is about the **handshake era**. Revisions from `2026-07-28` on have no `initialize` and nothing to negotiate — each request names its own revision. `build()` serves both eras from one configuration; see -[The 2026-07-28 lifecycle](../lifecycle/index.md) and, for narrowing or removing the modern leg, -[Serving both eras](../lifecycle/serving-both-eras.md). +[The 2026-07-28 lifecycle](../protocol-versions.md) and, for narrowing or removing the modern leg, +[Serving both eras](protocol-eras.md). MCP revisions are identified by a date string such as `2025-11-25`. The client names the revision it wants to speak in its `initialize` request, and the server answers with the revision the connection will actually use. Both sides @@ -154,12 +154,12 @@ counter-offer and has to decide whether to continue. Leave it unset unless you h `setProtocolVersion()` only pins the handshake. To narrow or remove what the modern leg answers for, use `setModernVersions()` / `withoutModernEra()` — see -[Serving both eras](../lifecycle/serving-both-eras.md#serving-one-era-only). +[Serving both eras](protocol-eras.md#serving-one-era-only). ## The 2026-07-28 Lifecycle `build()` returns a server that answers both protocol eras, and none of the following is required to serve either -one. Each knob is covered in depth in [The 2026-07-28 lifecycle](../lifecycle/index.md): +one. Each knob is covered in depth in [The 2026-07-28 lifecycle](../protocol-versions.md): ```php use Mcp\Schema\Enum\CacheScope; diff --git a/docs/run/sessions.md b/docs/run/sessions.md index 5bd93464..abdac607 100644 --- a/docs/run/sessions.md +++ b/docs/run/sessions.md @@ -2,7 +2,7 @@ > Sessions belong to the handshake era. Protocol revision `2026-07-28` removed them, along with > `Mcp-Session-Id`, because every request carries what is needed to answer it — see -> [The 2026-07-28 lifecycle](../lifecycle/index.md). One server serves both, so the configuration below still +> [The 2026-07-28 lifecycle](../protocol-versions.md). One server serves both, so the configuration below still > applies to the handshake-era clients it answers. Configure session storage and lifecycle. By default, the SDK uses `InMemorySessionStore`: diff --git a/docs/lifecycle/subscriptions.md b/docs/run/subscriptions.md similarity index 85% rename from docs/lifecycle/subscriptions.md rename to docs/run/subscriptions.md index 78b772f7..4c3e989f 100644 --- a/docs/lifecycle/subscriptions.md +++ b/docs/run/subscriptions.md @@ -1,5 +1,9 @@ # Subscriptions +> `subscriptions/listen` belongs to protocol revision `2026-07-28`; see +> [Protocol versions](../protocol-versions.md). Handshake-era clients keep using the HTTP `GET` stream and +> `resources/subscribe`, which the same server still answers. + `subscriptions/listen` replaces the HTTP `GET` stream and `resources/subscribe`. The client opens a long-lived POST whose response stream carries the notification types it asked for; the server acknowledges first with `notifications/subscriptions/acknowledged`, reporting the diff --git a/mkdocs.yml b/mkdocs.yml index 4bf405da..491441b4 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -28,6 +28,7 @@ nav: - Inside your handler: - handlers/index.md - Talking back to the client: handlers/client-communication.md + - Asking for input: handlers/input-required.md - Logging: handlers/logging.md - Running your server: - run/index.md @@ -36,6 +37,9 @@ nav: - HTTP transport: run/http.md - Framework integration: run/framework-integration.md - Sessions: run/sessions.md + - Serving both eras: run/protocol-eras.md + - Caching: run/caching.md + - Subscriptions: run/subscriptions.md - Authorization: run/authorization.md - Clients: - client/index.md @@ -44,14 +48,7 @@ nav: - "Tools, resources & prompts": client/capabilities.md - Server-initiated requests: client/server-requests.md - Error handling: client/errors.md - - The 2026-07-28 lifecycle: - - lifecycle/index.md - - What travels with a request: lifecycle/requests.md - - Asking for input: lifecycle/input-required.md - - Caching: lifecycle/caching.md - - Subscriptions: lifecycle/subscriptions.md - - Serving both eras: lifecycle/serving-both-eras.md - - Clients on this revision: lifecycle/client.md + - Protocol versions: protocol-versions.md - Advanced: - advanced/index.md - Events: advanced/events.md From e265a6d8bdc33526721fa74c8b05fc20e59319fd Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Wed, 19 Aug 2026 23:38:35 +0200 Subject: [PATCH 10/13] [Docs] Move protocol version negotiation onto the Protocol versions page The builder page keeps the setProtocolVersion() knob and links out; how a revision is agreed now sits beside the era it belongs to. --- docs/client/connecting.md | 2 +- docs/protocol-versions.md | 67 +++++++++++++++++++++++++++++++-- docs/run/http.md | 2 +- docs/run/server-builder.md | 76 +++++--------------------------------- 4 files changed, 74 insertions(+), 73 deletions(-) diff --git a/docs/client/connecting.md b/docs/client/connecting.md index 41887f0c..6b434631 100644 --- a/docs/client/connecting.md +++ b/docs/client/connecting.md @@ -82,7 +82,7 @@ Setting a modern revision such as `2026-07-28` selects the other lifecycle rathe `initialize` to negotiate with, so `connect()` sends none and every request carries its own revision instead. Nothing else about the client API changes. See [Clients on this revision](../protocol-versions.md) for what happens underneath. -See [Protocol Version Negotiation](../run/server-builder.md#protocol-version-negotiation) for the server side of the +See [Protocol versions](../protocol-versions.md#negotiating-in-the-handshake-era) for the server side of the exchange. ### Capabilities diff --git a/docs/protocol-versions.md b/docs/protocol-versions.md index 97a5248d..86a8d0b1 100644 --- a/docs/protocol-versions.md +++ b/docs/protocol-versions.md @@ -23,10 +23,69 @@ map; the mechanics live with the task they belong to. | HTTP entry | `StreamableHttpTransport` — the same one, for both | `ProtocolVersion::isModern()` tells the two apart, and -`Mcp\Schema\Enum\ProtocolVersion::FIRST_MODERN_VERSION` is where the boundary sits. How the -handshake era agrees on a revision is -[Protocol version negotiation](run/server-builder.md#protocol-version-negotiation); the -modern era negotiates nothing. +`Mcp\Schema\Enum\ProtocolVersion::FIRST_MODERN_VERSION` is where the boundary sits. + +## Negotiating in the handshake era + +Revisions up to `2025-11-25` agree on one during `initialize`. The client names the revision it wants to speak, and +the server answers with the revision the connection will actually use. Both sides disconnect if they cannot agree. +This follows the +[protocol version negotiation](https://modelcontextprotocol.io/specification/draft/basic/versioning#protocol-version-negotiation) +section of the specification. The modern era negotiates nothing — each request names its own revision. + +The SDK's known revisions live in `Mcp\Schema\Enum\ProtocolVersion`, declared oldest to newest: + +```php +use Mcp\Schema\Enum\ProtocolVersion; + +ProtocolVersion::latestHandshake(); // newest revision reachable via `initialize` +ProtocolVersion::handshakeVersions(); // every revision the server will negotiate, oldest first +ProtocolVersion::modernVersions(); // every revision served without a handshake +ProtocolVersion::V2025_11_25->isAtLeast(ProtocolVersion::V2025_06_18); // true +``` + +Comparisons go through declaration order rather than string collation. The identifiers happen to be ISO dates today, +but they are an enumerated set rather than an ordered scalar, so nothing should assume they sort chronologically. + +### How the server answers + +| Client requests | Server responds with | +| --- | --- | +| A revision the server supports | That same revision | +| An unknown or malformed revision | `ProtocolVersion::latestHandshake()` as a counter-offer | +| A modern revision such as `2026-07-28` | `ProtocolVersion::latestHandshake()` as a counter-offer | + +A counter-offer is not an error: the client decides whether it can continue on the offered revision or must close the +connection. The negotiated revision is stored on the session under `protocol_version`. + +The last row is not a rejection of an unknown revision — the SDK knows `2026-07-28`, it just cannot be reached through +this handshake. The modern era replaced `initialize` with per-request metadata, so answering with one of its revisions +would leave a connection neither side could use. A client speaking it never gets here: it sends the envelope instead +of an `initialize` request, and the transport routes it to the modern dispatcher before any negotiation is attempted. +See [Serving both eras](run/protocol-eras.md). + +This table is mirrored by the `provideNegotiationTable()` data provider in +`tests/Unit/Server/Handler/Request/InitializeHandlerTest.php`, which drives its supported-revision rows off the enum so +a newly declared revision is covered automatically. + +### Pinning a revision + +`Builder::setProtocolVersion()` pins the handshake to exactly one revision instead of negotiating across the supported +set. The pin wins over the client's request, so a client asking for anything else receives the pinned revision as a +counter-offer and has to decide whether to continue. Leave it unset unless you have a reason to refuse other +revisions. + +It pins the handshake era only. `setModernVersions()` narrows what the modern leg answers for, and +`withoutModernEra()` removes that leg altogether — see +[Serving one era only](run/protocol-eras.md#serving-one-era-only). + +!!! note + On the Streamable HTTP transport, every handshake-era request after the handshake also carries an + `MCP-Protocol-Version` header, which is validated separately by `ProtocolVersionMiddleware`. The pin does not reach + that check: the transport builds the middleware without access to the server configuration, so the header keeps + being accepted for every revision in `ProtocolVersion::handshakeVersions()`. To narrow it too, construct the + middleware yourself with the same revision — see + [Protocol Version Validation](run/http.md#protocol-version-validation). ## What changes, and where it is written down diff --git a/docs/run/http.md b/docs/run/http.md index 04292b0c..1f818207 100644 --- a/docs/run/http.md +++ b/docs/run/http.md @@ -154,7 +154,7 @@ The default set is `ProtocolVersion::handshakeVersions()` — every revision the header itself, so a header-less request cannot be newer than that. This header check is separate from, and happens after, the handshake itself. See -[Protocol Version Negotiation](server-builder.md#protocol-version-negotiation) for how the revision is agreed in the +[Protocol versions](../protocol-versions.md#negotiating-in-the-handshake-era) for how the revision is agreed in the first place. Being separate also means it is unaffected by `setProtocolVersion()`: the middleware validates against the set it was constructed with, not against the revision a given session negotiated, so a server that pins the handshake has to pass that revision here as well. diff --git a/docs/run/server-builder.md b/docs/run/server-builder.md index e6f4d6b8..04620c6d 100644 --- a/docs/run/server-builder.md +++ b/docs/run/server-builder.md @@ -81,8 +81,7 @@ $server = Server::builder() ### Protocol Version By default the server negotiates the protocol revision with each client during the `initialize` handshake, and you do -not need to configure anything. See [Protocol Version Negotiation](#protocol-version-negotiation) below for how that -negotiation resolves, and for what `setProtocolVersion()` changes: +not need to configure anything. `setProtocolVersion()` pins that handshake to exactly one revision instead: ```php use Mcp\Schema\Enum\ProtocolVersion; @@ -91,75 +90,18 @@ $server = Server::builder() ->setProtocolVersion(ProtocolVersion::V2025_06_18); ``` -## Protocol Version Negotiation - -This section is about the **handshake era**. Revisions from `2026-07-28` on have no `initialize` and nothing to -negotiate — each request names its own revision. `build()` serves both eras from one configuration; see -[The 2026-07-28 lifecycle](../protocol-versions.md) and, for narrowing or removing the modern leg, -[Serving both eras](protocol-eras.md). - -MCP revisions are identified by a date string such as `2025-11-25`. The client names the revision it wants to speak in -its `initialize` request, and the server answers with the revision the connection will actually use. Both sides -disconnect if they cannot agree. This follows the -[protocol version negotiation](https://modelcontextprotocol.io/specification/draft/basic/versioning#protocol-version-negotiation) -section of the specification. - -The SDK's known revisions live in `Mcp\Schema\Enum\ProtocolVersion`, declared oldest to newest: - -```php -use Mcp\Schema\Enum\ProtocolVersion; - -ProtocolVersion::latestHandshake(); // newest revision reachable via `initialize` -ProtocolVersion::handshakeVersions(); // every revision the server will negotiate, oldest first -ProtocolVersion::V2025_11_25->isAtLeast(ProtocolVersion::V2025_06_18); // true -``` - -Comparisons go through declaration order rather than string collation. The identifiers happen to be ISO dates today, -but they are an enumerated set rather than an ordered scalar, so nothing should assume they sort chronologically. - -### How the server answers - -| Client requests | Server responds with | -| --- | --- | -| A revision the server supports | That same revision | -| An unknown or malformed revision | `ProtocolVersion::latestHandshake()` as a counter-offer | -| A modern revision such as `2026-07-28` | `ProtocolVersion::latestHandshake()` as a counter-offer | - -A counter-offer is not an error: the client decides whether it can continue on the offered revision or must close the -connection. The negotiated revision is stored on the session under `protocol_version`. - -The last row is not a rejection of an unknown revision — the SDK knows `2026-07-28`, it just cannot be reached through -this handshake. The modern era replaced `initialize` with per-request metadata, so answering with one of its revisions -would leave a connection neither side could use. The server does serve that era: a client speaking it sends the -envelope instead of an `initialize` request, and the transport routes it to the modern dispatcher without any -negotiation happening at all. - -This table is mirrored by the `provideNegotiationTable()` data provider in -`tests/Unit/Server/Handler/Request/InitializeHandlerTest.php`, which drives its supported-revision rows off the enum so -a newly declared revision is covered automatically. - -### Pinning a revision - -`setProtocolVersion()` pins the handshake to exactly one revision instead of negotiating across the supported set. The -pin wins over the client's request, so a client asking for anything else receives the pinned revision as a -counter-offer and has to decide whether to continue. Leave it unset unless you have a reason to refuse other revisions. - -!!! note - On the Streamable HTTP transport, every handshake-era request after the handshake also carries an - `MCP-Protocol-Version` header, which is validated separately by `ProtocolVersionMiddleware`. The pin does not reach - that check: the transport builds the middleware without access to the server configuration, so the header keeps - being accepted for every revision in `ProtocolVersion::handshakeVersions()`. To narrow it too, construct the - middleware yourself with the same revision — see - [Protocol Version Validation](http.md#protocol-version-validation). - -`setProtocolVersion()` only pins the handshake. To narrow or remove what the modern leg answers for, use +See [Protocol versions](../protocol-versions.md#negotiating-in-the-handshake-era) for how negotiation resolves and +what pinning changes. It only pins the handshake era; to narrow or remove what the modern leg answers for, use `setModernVersions()` / `withoutModernEra()` — see [Serving both eras](protocol-eras.md#serving-one-era-only). -## The 2026-07-28 Lifecycle +## Modern-Era Options -`build()` returns a server that answers both protocol eras, and none of the following is required to serve either -one. Each knob is covered in depth in [The 2026-07-28 lifecycle](../protocol-versions.md): +`build()` returns a server that answers both [protocol eras](../protocol-versions.md), and none of the following is +required to serve either one. Each knob is covered in depth elsewhere: +[Asking for input](../handlers/input-required.md) for the first two, +[Caching](caching.md), [Subscriptions](subscriptions.md), and +[Serving both eras](protocol-eras.md) for the last. ```php use Mcp\Schema\Enum\CacheScope; From 384d918c9a2e33e2cc5ba07ce8df36cd84a903f9 Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Wed, 19 Aug 2026 23:57:45 +0200 Subject: [PATCH 11/13] [Docs] Slim the README to a funnel, turn Examples into an index --- README.md | 258 ++------------ docs/examples.md | 728 +++----------------------------------- examples/client/README.md | 19 + examples/server/README.md | 6 +- 4 files changed, 109 insertions(+), 902 deletions(-) diff --git a/README.md b/README.md index cd8d311a..194230a2 100644 --- a/README.md +++ b/README.md @@ -14,8 +14,9 @@ -The official PHP SDK for Model Context Protocol (MCP). It provides a framework-agnostic API for implementing MCP servers -and clients in PHP. +The official PHP SDK for the Model Context Protocol (MCP). It provides a framework-agnostic API for implementing MCP +servers and clients in PHP — tools, resources, prompts, STDIO and HTTP transports, sessions, authorization, and both +protocol eras (the `initialize` handshake and the stateless `2026-07-28` revision). This project represents a collaboration between [the PHP Foundation](https://thephp.foundation/) and the [Symfony project](https://symfony.com/). It adopts development practices and standards from the Symfony project, including [Coding Standards](https://symfony.com/doc/current/contributing/code/standards.html) and the @@ -24,47 +25,27 @@ development practices and standards from the Symfony project, including [Coding Until the first major release, this SDK is considered [experimental](https://symfony.com/doc/current/contributing/code/experimental.html), please see the [roadmap](./ROADMAP.md) for planned next steps and features. -## Table of Contents - -- [Installation](#installation) -- [Overview](#overview) -- [Server SDK](#server-sdk) -- [Client SDK](#client-sdk) -- [Documentation](#documentation) -- [External Resources](#external-resources) -- [PHP Libraries Using the MCP SDK](#php-libraries-using-the-mcp-sdk) -- [Contributing](#contributing) -- [Credits](#credits) -- [License](#license) - ## Installation ```bash composer require mcp/sdk ``` -## Overview - -The MCP PHP SDK provides both **server** and **client** implementations for the Model Context Protocol, enabling you to: - -- **Build MCP Servers**: Expose your PHP application's functionality (tools, resources, prompts) to AI agents -- **Build MCP Clients**: Connect to and interact with MCP servers from your PHP applications - -## Server SDK - -Build MCP servers to expose your PHP application's capabilities to AI agents like Claude, Codex, and others. +## Build a server -### Quick Start +A server is a plain PHP class plus three lines of wiring: ```php +use Mcp\Capability\Attribute\McpResource; +use Mcp\Capability\Attribute\McpTool; use Mcp\Server; use Mcp\Server\Transport\StdioTransport; -use Mcp\Capability\Attribute\McpTool; -use Mcp\Capability\Attribute\McpResource; -// Define capabilities using PHP attributes -class CalculatorCapabilities +class Calculator { + /** + * Adds two numbers. + */ #[McpTool] public function add(int $a, int $b): int { @@ -72,240 +53,57 @@ class CalculatorCapabilities } #[McpResource(uri: 'config://calculator/settings')] - public function getSettings(): array + public function settings(): array { return ['precision' => 2]; } } -// Build and run the server -$server = Server::builder() - ->setServerInfo('Calculator Server', '1.0.0') - ->setDiscovery(__DIR__, ['.']) // Auto-discover attributes - ->build(); - -$transport = new StdioTransport(); -$server->run($transport); -``` - -### Server Capabilities - -- **Tools**: Executable functions that AI agents can call -- **Resources**: Data sources that can be read (files, configs, databases) -- **Resource Templates**: Dynamic resources with URI parameters -- **Prompts**: Pre-defined templates for AI interactions -- **Server-Initiated Communication**: Elicitations, sampling, logging, progress notifications - -### Registration Methods - -There are multiple ways to register your MCP capabilities—choose the approach that best fits your application's architecture: - -**1. Attribute-Based Discovery** — Define capabilities using PHP attributes for automatic discovery: -```php -#[McpTool] -public function generateReport(): string { /* ... */ } - -#[McpResource(uri: 'config://app/settings')] -public function getConfig(): array { /* ... */ } -``` - -**2. Manual Registration** — Register capabilities programmatically without attributes: -```php -$server = Server::builder() - ->addTool([Calculator::class, 'add'], 'add_numbers') - ->addResource([Config::class, 'get'], 'config://app') - ->build(); -``` - -**3. Hybrid Approach** — Combine both methods for maximum flexibility: -```php -$server = Server::builder() - ->setDiscovery(__DIR__, ['.']) - ->addTool([ExternalService::class, 'process'], 'external') - ->build(); -``` - -### Transports - -Choose the transport that matches your deployment environment: - -**1. STDIO Transport** — For command-line integration and local processes: -```php -$transport = new StdioTransport(); -$server->run($transport); -``` - -**2. HTTP Transport** — For web-based servers and distributed systems: -```php -$transport = new StreamableHttpTransport($request, $responseFactory, $streamFactory); -$response = $server->run($transport); +exit(Server::builder() + ->setServerInfo('Calculator', '1.0.0') + ->setDiscovery(__DIR__, ['.'], excludeDirs: ['vendor']) + ->build() + ->run(new StdioTransport())); ``` -### Session Management +The walkthrough in [First server](docs/get-started/first-server.md) explains each piece, and +[Try it with the Inspector](docs/get-started/inspector.md) shows it running. -Configure session storage to maintain state between requests. Choose the backend that fits your infrastructure: - -**In-Memory** (default, suitable for STDIO): -```php -$server = Server::builder() - ->setSession(ttl: 7200) // 2 hours - ->build(); -``` - -**File-Based** (suitable for single-server HTTP deployments): -```php -$server = Server::builder() - ->setSession(new FileSessionStore(__DIR__ . '/sessions')) - ->build(); -``` - -**PSR-16 Cache** (for example with Redis for scaled deployments): -```php -$server = Server::builder() - ->setSession(new Psr16SessionStore( - cache: new Psr16Cache($redisAdapter), - prefix: 'mcp-', - ttl: 3600 - )) - ->build(); -``` - -[→ Server Documentation](docs/run/server-builder.md) - -## Client SDK - -Connect to MCP servers from your PHP applications to access their tools, resources, and prompts. - -### Quick Start +## Build a client ```php use Mcp\Client; use Mcp\Client\Transport\StdioTransport; -// Build the client $client = Client::builder() ->setClientInfo('My Application', '1.0.0') - ->setInitTimeout(30) - ->setRequestTimeout(120) ->build(); -// Connect to a server -$transport = new StdioTransport( - command: 'php', - args: ['/path/to/server.php'], -); - -$client->connect($transport); +$client->connect(new StdioTransport(command: 'php', args: ['/path/to/server.php'])); -// Discover and use capabilities $tools = $client->listTools(); $result = $client->callTool('add', ['a' => 5, 'b' => 3]); -$resources = $client->listResources(); -$content = $client->readResource('config://calculator/settings'); - $client->disconnect(); ``` -### Client Capabilities - -- **Tool Calling**: List and execute tools from any MCP server -- **Resource Access**: Read static and dynamic resources -- **Prompt Management**: List and retrieve prompt templates -- **Completion Support**: Request argument completion suggestions -- **Sampling & Elicitation**: Respond to server-initiated LLM sampling and user-input requests - -### Advanced Features - -- **Progress Tracking**: Real-time progress during long operations -```php -$result = $client->callTool( - name: 'process_data', - arguments: ['dataset' => 'large_file.csv'], - onProgress: function (float $progress, ?float $total, ?string $message) { - echo "Progress: {$progress}/{$total} - {$message}\n"; - } -); -``` - -- **Sampling Support**: Handle server LLM sampling requests -```php -$samplingHandler = new SamplingRequestHandler($myCallback); -$client = Client::builder() - ->setCapabilities(new ClientCapabilities(sampling: true)) - ->addRequestHandler($samplingHandler) - ->build(); -``` - -- **Elicitation Support**: Respond to server requests for user input -```php -$elicitationHandler = new ElicitationRequestHandler($myCallback); -$client = Client::builder() - ->setCapabilities(new ClientCapabilities(elicitation: true)) - ->addRequestHandler($elicitationHandler) - ->build(); -``` - -- **Roots Support**: Expose `file://` workspace folders to the server -```php -$rootsHandler = new ListRootsRequestHandler($myCallback); -$client = Client::builder() - ->setCapabilities(new ClientCapabilities(roots: true, rootsListChanged: true)) - ->addRequestHandler($rootsHandler) - ->build(); -``` - -- **Logging Notifications**: Receive server log messages -```php -$loggingHandler = new LoggingNotificationHandler($myCallback); -$client = Client::builder() - ->addNotificationHandler($loggingHandler) - ->build(); -``` - -### Transports - -Connect to MCP servers using the transport that matches your setup: - -**1. STDIO Transport** — Connect to local server processes: -```php -$transport = new StdioTransport( - command: 'php', - args: ['/path/to/server.php'], -); - -$client->connect($transport); -``` - -**2. HTTP Transport** — Connect to remote or web-based servers: -```php -$transport = new HttpTransport('http://localhost:8000'); - -$client->connect($transport); -``` - -[→ Client Documentation](docs/client/index.md) +See [Connecting to a server](docs/client/connecting.md) for transports, timeouts, and the +handlers that answer server-initiated requests. ## Documentation The full documentation is published at **[php.sdk.modelcontextprotocol.io](https://php.sdk.modelcontextprotocol.io/)**. -### Core Concepts - - **[Get started](docs/get-started/index.md)** — Install the SDK and build your first server - **[Servers](docs/servers/index.md)** — Tools, resources, resource templates, prompts, and how to register them -- **[Inside your handler](docs/handlers/index.md)** — Sampling, logging, progress, and notifications from within a handler +- **[Inside your handler](docs/handlers/index.md)** — Talking back to the client, logging, and asking for input - **[Running your server](docs/run/index.md)** — Server builder, STDIO and HTTP transports, framework integration, sessions, authorization - **[Clients](docs/client/index.md)** — Client SDK for connecting to and communicating with MCP servers - **[Protocol versions](docs/protocol-versions.md)** — The two protocol eras, and what revision `2026-07-28` changed - **[Advanced](docs/advanced/index.md)** — Events, protocol extensions (including MCP Apps), and custom message handlers +- **[Examples](docs/examples.md)** — Runnable server and client examples - **[API Reference](https://php.sdk.modelcontextprotocol.io/api/)** — Generated class reference -### Learning & Examples - -- **[Examples](docs/examples.md)** — Comprehensive example walkthroughs for servers and clients -- **[ROADMAP.md](ROADMAP.md)** — Planned features and development roadmap - ## External Resources - **[Model Context Protocol Documentation](https://modelcontextprotocol.io)** — Official MCP documentation @@ -326,9 +124,9 @@ Building something on top of the SDK? Open a pull request to add it to this list ## Contributing -We are passionate about supporting contributors of all levels of experience and would love to see you get involved in the project. - -See the [Contributing Guide](CONTRIBUTING.md) to get started before you [report issues](https://github.com/modelcontextprotocol/php-sdk/issues) and [send pull requests](https://github.com/modelcontextprotocol/php-sdk/pulls). +We are passionate about supporting contributors of all levels of experience and would love to see you get involved in +the project. Start by [reporting issues](https://github.com/modelcontextprotocol/php-sdk/issues) or +[sending pull requests](https://github.com/modelcontextprotocol/php-sdk/pulls). ## Credits diff --git a/docs/examples.md b/docs/examples.md index 1830aa14..4eae5ba4 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -1,404 +1,74 @@ # Examples -The MCP PHP SDK includes comprehensive examples demonstrating different patterns and use cases. Each example showcases -specific features and can be run independently to understand how the SDK works. - -## Getting Started - -All examples are located in the `examples/` directory and use the SDK dependencies from the root project. Most examples -can be run directly without additional setup. - -### Prerequisites +Every example lives in [`examples/`](https://github.com/modelcontextprotocol/php-sdk/tree/main/examples) +and runs against the dependencies installed in the repository root. Each server example is a +`server.php` whose bootstrap picks the transport from the SAPI it runs under: ```bash -# Install dependencies (in project root) -composer install -``` - -## Running Examples - -The bootstrapping of the example will choose the used transport based on the SAPI you use. +# STDIO transport +php examples/server/discovery-calculator/server.php -### STDIO Transport +# Streamable HTTP transport +php -S 127.0.0.1:8000 examples/server/discovery-userprofile/server.php -The STDIO transport will use standard input/output for communication: - -```bash -# Interactive testing with MCP Inspector +# Interactive testing with the MCP Inspector npx @modelcontextprotocol/inspector php examples/server/discovery-calculator/server.php - -# Run with debugging enabled -npx @modelcontextprotocol/inspector -e DEBUG=1 -e FILE_LOG=1 php examples/server/discovery-calculator/server.php - -# Or configure the script path in your MCP client -# Path: php examples/server/discovery-calculator/server.php ``` -### HTTP Transport - -The Streamable HTTP transport will be chosen if running examples with a web servers: - -```bash -# Start the server -php -S localhost:8000 examples/server/discovery-userprofile/server.php - -# Test with MCP Inspector -npx @modelcontextprotocol/inspector http://localhost:8000 - -# Test with curl -curl -X POST http://localhost:8000 \ - -H "Content-Type: application/json" \ - -H "Accept: application/json, text/event-stream" \ - -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","clientInfo":{"name":"test","version":"1.0.0"},"capabilities":{}}}' -``` - -## Server Examples - -### Discovery Calculator - -**File**: `examples/server/discovery-calculator/` - -**What it demonstrates:** -- Attribute-based discovery using `#[McpTool]` and `#[McpResource]` -- Basic arithmetic operations -- Configuration management through resources -- State management between tool calls - -**Key Features:** -```php -#[McpTool(name: 'calculate')] -public function calculate(float $a, float $b, string $operation): float|string - -#[McpResource( - uri: 'config://calculator/settings', - name: 'calculator_config', - mimeType: 'application/json' -)] -public function getConfiguration(): array -``` +## Server examples + +| Example | What it shows | Docs | +| --- | --- | --- | +| [`discovery-calculator`](https://github.com/modelcontextprotocol/php-sdk/tree/main/examples/server/discovery-calculator) | Attribute discovery of tools, resources and prompts; `ResourceLink` content | [Tools](servers/tools.md) | +| [`discovery-userprofile`](https://github.com/modelcontextprotocol/php-sdk/tree/main/examples/server/discovery-userprofile) | Resource templates with completion providers; `FileSessionStore` | [Resource templates](servers/resource-templates.md), [Completions](servers/completions.md) | +| [`explicit-registration`](https://github.com/modelcontextprotocol/php-sdk/tree/main/examples/server/explicit-registration) | Manual `addTool()`/`addResource()`/`addResourceTemplate()`/`addPrompt()` without discovery | [Registering elements](servers/registration.md) | +| [`combined-registration`](https://github.com/modelcontextprotocol/php-sdk/tree/main/examples/server/combined-registration) | Discovery and manual registration combined, and which wins on conflict | [Registering elements](servers/registration.md) | +| [`cached-discovery`](https://github.com/modelcontextprotocol/php-sdk/tree/main/examples/server/cached-discovery) | Caching the discovery scan in a PSR-16 cache | [Server builder](run/server-builder.md#discovery-configuration) | +| [`custom-dependencies`](https://github.com/modelcontextprotocol/php-sdk/tree/main/examples/server/custom-dependencies) | Handlers pulling services from a PSR-11 container | [Server builder](run/server-builder.md#service-dependencies) | +| [`complex-tool-schema`](https://github.com/modelcontextprotocol/php-sdk/tree/main/examples/server/complex-tool-schema) | Rich input schemas from typed parameters, PHP enums and defaults | [Schema generation](servers/schemas.md) | +| [`schema-showcase`](https://github.com/modelcontextprotocol/php-sdk/tree/main/examples/server/schema-showcase) | `#[Schema]` constraint attributes: formats, patterns, ranges | [Schema generation](servers/schemas.md) | +| [`env-variables`](https://github.com/modelcontextprotocol/php-sdk/tree/main/examples/server/env-variables) | Configuring a server through environment variables | [Server builder](run/server-builder.md) | +| [`client-communication`](https://github.com/modelcontextprotocol/php-sdk/tree/main/examples/server/client-communication) | Sampling, roots, progress and log messages from inside a handler | [Talking back to the client](handlers/client-communication.md) | +| [`client-logging`](https://github.com/modelcontextprotocol/php-sdk/tree/main/examples/server/client-logging) | Structured log notifications through the `ClientLogger` | [Logging](handlers/logging.md) | +| [`elicitation`](https://github.com/modelcontextprotocol/php-sdk/tree/main/examples/server/elicitation) | Asking the user for input mid-call with `InputRequiredResult` and typed elicitation schemas | [Asking for input](handlers/input-required.md) | +| [`custom-method-handlers`](https://github.com/modelcontextprotocol/php-sdk/tree/main/examples/server/custom-method-handlers) | Registering handlers for custom JSON-RPC methods | [Custom message handlers](advanced/custom-handlers.md) | +| [`mcp-apps`](https://github.com/modelcontextprotocol/php-sdk/tree/main/examples/server/mcp-apps) | The MCP Apps extension: a tool that ships an interactive HTML view | [Protocol extensions](advanced/extensions.md) | +| [`stateless-lifecycle`](https://github.com/modelcontextprotocol/php-sdk/tree/main/examples/server/stateless-lifecycle) | Revision `2026-07-28`: cache policy, request state, notification bus | [Serving both eras](run/protocol-eras.md), [Caching](run/caching.md), [Subscriptions](run/subscriptions.md) | +| [`oauth-keycloak`](https://github.com/modelcontextprotocol/php-sdk/tree/main/examples/server/oauth-keycloak) | OAuth authorization against a Keycloak instance (own README) | [Authorization](run/authorization.md) | +| [`oauth-microsoft`](https://github.com/modelcontextprotocol/php-sdk/tree/main/examples/server/oauth-microsoft) | OAuth authorization against Microsoft Entra ID (own README) | [Authorization](run/authorization.md) | + +## Client examples + +| Example | What it shows | Docs | +| --- | --- | --- | +| [`stdio_discovery_calculator.php`](https://github.com/modelcontextprotocol/php-sdk/blob/main/examples/client/stdio_discovery_calculator.php) | Connecting over STDIO, listing and calling tools, reading resources | [Connecting to a server](client/connecting.md) | +| [`http_discovery_calculator.php`](https://github.com/modelcontextprotocol/php-sdk/blob/main/examples/client/http_discovery_calculator.php) | The same conversation over the Streamable HTTP transport | [Transports](client/transports.md) | +| [`stdio_client_communication.php`](https://github.com/modelcontextprotocol/php-sdk/blob/main/examples/client/stdio_client_communication.php) | Answering server-initiated sampling, log and progress messages | [Server-initiated requests](client/server-requests.md) | +| [`http_client_communication.php`](https://github.com/modelcontextprotocol/php-sdk/blob/main/examples/client/http_client_communication.php) | The same handlers over HTTP — see the note on PHP's built-in server below | [Server-initiated requests](client/server-requests.md) | +| [`stdio_elicitation.php`](https://github.com/modelcontextprotocol/php-sdk/blob/main/examples/client/stdio_elicitation.php) | Answering elicitation requests from an interactive prompt | [Server-initiated requests](client/server-requests.md) | +| [`stdio_roots.php`](https://github.com/modelcontextprotocol/php-sdk/blob/main/examples/client/stdio_roots.php) | Exposing workspace roots and signalling `roots/list_changed` | [Server-initiated requests](client/server-requests.md) | +| [`stateless_lifecycle_client.php`](https://github.com/modelcontextprotocol/php-sdk/blob/main/examples/client/stateless_lifecycle_client.php) | A client pinned to revision `2026-07-28` — see [Modern-era client](#modern-era-client) | [Connecting to a server](client/connecting.md) | + +Client examples run directly: -**Usage:** ```bash -# Interactive testing -npx @modelcontextprotocol/inspector php examples/server/discovery-calculator/server.php - -# Or configure in MCP client: php examples/server/discovery-calculator/server.php -``` - -### Explicit Registration - -**File**: `examples/server/explicit-registration/` - -**What it demonstrates:** -- Manual registration of tools, resources, and prompts -- Alternative to attribute-based discovery -- Simple handler functions - -**Key Features:** -```php -$server = Server::builder() - ->addTool([SimpleHandlers::class, 'echoText'], 'echo_text') - ->addResource([SimpleHandlers::class, 'getAppVersion'], 'app://version') - ->addPrompt([SimpleHandlers::class, 'greetingPrompt'], 'personalized_greeting') -``` - -### Environment Variables - -**File**: `examples/server/env-variables/` - -**What it demonstrates:** -- Environment variable integration -- Server configuration from environment -- Environment-based tool behavior - -**Key Features:** -- Reading environment variables within tools -- Conditional behavior based on environment -- Environment validation and defaults - -### Custom Dependencies - -**File**: `examples/server/custom-dependencies/` - -**What it demonstrates:** -- Dependency injection with PSR-11 containers -- Service layer architecture -- Repository pattern implementation -- Complex business logic integration - -**Key Features:** -```php -$container->set(TaskRepositoryInterface::class, $taskRepo); -$container->set(StatsServiceInterface::class, $statsService); - -$server = Server::builder() - ->setContainer($container) - ->setDiscovery(__DIR__, ['.']) -``` - -### Cached Discovery - -**File**: `examples/server/cached-discovery/` - -**What it demonstrates:** -- Discovery caching for improved performance -- PSR-16 cache integration -- Cache invalidation strategies - -**Key Features:** -```php -use Symfony\Component\Cache\Adapter\FilesystemAdapter; -use Symfony\Component\Cache\Psr16Cache; - -$cache = new Psr16Cache(new FilesystemAdapter('mcp-discovery')); - -$server = Server::builder() - ->setDiscovery(__DIR__, ['.'], [], $cache) -``` - -### Client Communication - -**File**: `examples/server/client-communication/` - -**What it demonstrates:** -- Server initiated communication back to the client -- Logging, sampling, progress and notifications -- Using `ClientGateway` in tool method via method argument injection of `RequestContext` -- Sampling and roots asked for the [multi round-trip](handlers/input-required.md) way, so the - same tools serve a handshake-era and a `2026-07-28` client without naming either - -### Discovery User Profile - -**File**: `examples/server/discovery-userprofile/` - -**What it demonstrates:** -- HTTP transport with StreamableHttpTransport -- Resource templates with URI parameters -- Completion providers for parameter hints -- User profile management system -- Session persistence with FileSessionStore - -**Key Features:** -```php -#[McpResourceTemplate( - uriTemplate: 'user://{userId}/profile', - name: 'user_profile', - mimeType: 'application/json' -)] -public function getUserProfile( - #[CompletionProvider(values: ['101', '102', '103'])] - string $userId -): array - -#[McpPrompt(name: 'generate_bio_prompt')] -public function generateBio(string $userId, string $tone = 'professional'): array -``` - -**Usage:** -```bash -# Start the HTTP server -php -S localhost:8000 examples/server/discovery-userprofile/server.php - -# Test with MCP Inspector -npx @modelcontextprotocol/inspector http://localhost:8000 - -# Or configure in MCP client: http://localhost:8000 -``` - -### Combined Registration - -**File**: `examples/server/combined-registration/` - -**What it demonstrates:** -- Mixing attribute discovery with manual registration -- HTTP server with both discovered and manual capabilities -- All three handler styles: discovered, `[Class::class, 'method']`, and a pre-built `[$instance, 'method']` -- Pre-built instance handlers for classes the container cannot auto-wire (e.g. constructor scalars) - -**Key Features:** -```php -// Built here so its constructor dependencies are injected before registration; -// the SDK invokes this very instance instead of constructing one itself. -$preconfiguredGreeter = new PreconfiguredGreeter('Willkommen', logger()); - -$server = Server::builder() - ->setDiscovery(__DIR__, ['.']) // Automatic discovery - ->addTool([ManualHandlers::class, 'manualGreeter']) // Manual class-string handler - ->addTool([$preconfiguredGreeter, 'greet'], 'instance_greeter') // Pre-built instance handler - ->addResource([ManualHandlers::class, 'getPriorityConfigManual'], 'config://priority') -``` - -### Complex Tool Schema - -**File**: `examples/server/complex-tool-schema/` - -**What it demonstrates:** -- Advanced JSON schema definitions -- Complex data structures and validation -- Event scheduling and management -- Enum types and nested objects - -**Key Features:** -```php -#[Schema(definition: [ - 'type' => 'object', - 'properties' => [ - 'title' => ['type' => 'string', 'minLength' => 1, 'maxLength' => 100], - 'eventType' => ['type' => 'string', 'enum' => ['meeting', 'deadline', 'reminder']], - 'priority' => ['type' => 'string', 'enum' => ['low', 'medium', 'high', 'urgent']] - ] -])] -public function scheduleEvent(array $eventData): array -``` - -### Schema Showcase - -**File**: `examples/server/schema-showcase/` - -**What it demonstrates:** -- Comprehensive JSON schema features -- Parameter-level schema validation -- String constraints (minLength, maxLength, pattern) -- Numeric constraints (minimum, maximum, multipleOf) -- Array and object validation - -**Key Features:** -```php -#[McpTool] -public function formatText( - #[Schema( - type: 'string', - minLength: 5, - maxLength: 100, - pattern: '^[a-zA-Z0-9\s\.,!?\-]+$' - )] - string $text, - - #[Schema(enum: ['uppercase', 'lowercase', 'title', 'sentence'])] - string $format = 'sentence' -): array -``` - -### Elicitation - -**File**: `examples/server/elicitation/` - -**What it demonstrates:** -- Asking the user for input from a tool, written the - [multi round-trip](handlers/input-required.md) way so one handler serves both protocol eras -- Interactive user input during tool execution -- Multi-field form schemas with validation -- Boolean confirmation dialogs -- Enum fields with human-readable labels -- Handling accept/decline/cancel responses -- Session persistence requirement for server-initiated requests - -**Key Features:** -```php -// Check client support before eliciting -if (!$context->getClientGateway()->supportsElicitation()) { - return ['status' => 'error', 'message' => 'Client does not support elicitation']; -} - -// Build schema with multiple field types -$schema = new ElicitationSchema( - properties: [ - 'party_size' => new NumberSchemaDefinition( - title: 'Party Size', - integerOnly: true, - minimum: 1, - maximum: 20 - ), - 'date' => new StringSchemaDefinition( - title: 'Reservation Date', - format: 'date' - ), - 'dietary' => new EnumSchemaDefinition( - title: 'Dietary Restrictions', - enum: ['none', 'vegetarian', 'vegan'], - enumNames: ['None', 'Vegetarian', 'Vegan'] - ), - ], - required: ['party_size', 'date'] -); - -// Return the ask, read the answer off the retry. The example wraps both halves -// in one helper, since every tool here does the same thing: -$result = $context->getInputContext()?->elicitResult('details') - ?? new InputRequiredResult(['details' => new ElicitRequest($message, $schema)]); - -// First round: hand the ask back to the client, which retries this whole call. -if ($result instanceof InputRequiredResult) { - return $result; -} - -// Handle response -if ($result->isAccepted()) { - $data = $result->content; // User-provided data -} elseif ($result->isDeclined() || $result->isCancelled()) { - // User declined or cancelled -} -``` - -**Important Notes:** -- The handler is re-entered from the top on the retry, so anything that must happen once - belongs behind the "do I have the answer yet?" check -- A handshake-era client reaches the same tool: the SDK's input-required shim turns the ask - into a real `elicitation/create` request over that connection -- Elicitation over a handshake-era connection requires a session store (e.g., `FileSessionStore`) -- Check client capabilities with `supportsElicitation()` before asking -- Schema supports primitive types: string, number/integer, boolean, enum -- String fields support format validation: date, date-time, email, uri -- Users can accept (providing data), decline, or cancel requests - -**Usage:** -```bash -# Interactive testing with MCP client that supports elicitation -npx @modelcontextprotocol/inspector php examples/server/elicitation/server.php - -# Test with Goose (confirmed working by reviewer) -# Or configure in Claude Desktop or other MCP clients +php examples/client/stdio_discovery_calculator.php ``` -**Example Tools:** -1. **book_restaurant** - Multi-field reservation form with number, date, and enum fields -2. **confirm_action** - Simple boolean confirmation dialog -3. **collect_feedback** - Rating and comments form with optional fields - -### MCP Apps - -**File**: `examples/server/mcp-apps/` - -A weather app demonstrating the [MCP Apps extension](advanced/extensions.md): a `ui://` -HTML resource is opened by an MCP App-aware client (e.g. Goose) and bridged to -the `get_weather` tool. The bundled `weather-app.html` performs the -`ui/initialize` handshake, reports its size via `ui/notifications/size-changed`, -and calls back into the server. See the -[ext-apps repo](https://github.com/modelcontextprotocol/ext-apps) for the -TypeScript SDK and richer view-side patterns. +> **Note**: PHP's built-in development server handles one request at a time, so the sampling +> round-trip in `http_client_communication.php` will not complete under a plain `php -S`. +> Start it with worker processes instead: `PHP_CLI_SERVER_WORKERS=2 php -S 127.0.0.1:8000 …`. -### The 2026-07-28 lifecycle +## The 2026-07-28 lifecycle -**File**: `examples/server/stateless-lifecycle/` +`stateless-lifecycle/server.php` answers both eras on the same URL. On revision `2026-07-28` +every request carries its own protocol version and client capabilities, so a call is a single +POST with no handshake before it: -A server speaking protocol revision `2026-07-28`, which removed the `initialize` handshake and -protocol-level sessions. It is HTTP-only and cannot be driven by the Inspector, which opens with -`initialize`. - -**What it demonstrates:** -- A tool answered in a single POST, with no handshake before it -- A [multi round-trip](handlers/input-required.md) tool that returns its ask and reads the answer - off the retry -- Progress and log notifications travelling on the request's own response stream -- Cache hints on `server/discover` and the list methods - -**Usage:** ```bash php -S 127.0.0.1:8000 examples/server/stateless-lifecycle/server.php ``` -Every request carries its own protocol version and client capabilities: - ```bash curl -sS http://127.0.0.1:8000/ \ -H 'Content-Type: application/json' \ @@ -410,298 +80,18 @@ curl -sS http://127.0.0.1:8000/ \ "io.modelcontextprotocol/clientCapabilities":{}}}}' ``` -`tests/Integration/StatelessLifecycleTest.php` drives this example end to end. See -[The 2026-07-28 lifecycle](protocol-versions.md) for the guide. - -## Client Examples - -### STDIO Discovery Calculator (Client) - -**File**: `examples/client/stdio_discovery_calculator.php` - -**What it demonstrates:** -- Basic MCP client usage with STDIO transport -- Connecting to a local MCP server process -- Listing and calling tools -- Reading resources - -**Key Features:** -```php -$client = Client::builder() - ->setClientInfo('STDIO Example Client', '1.0.0') - ->setInitTimeout(30) - ->setRequestTimeout(60) - ->build(); - -$transport = new StdioTransport( - command: 'php', - args: [__DIR__.'/../server/discovery-calculator/server.php'], -); - -$client->connect($transport); -$tools = $client->listTools(); -$result = $client->callTool('calculate', ['a' => 5, 'b' => 3, 'operation' => 'add']); -$resourceContent = $client->readResource('config://calculator/settings'); -``` - -**Usage:** -```bash -# Run the client (automatically starts the server) -php examples/client/stdio_discovery_calculator.php -``` - -### HTTP Discovery Calculator (Client) - -**File**: `examples/client/http_discovery_calculator.php` - -**What it demonstrates:** -- MCP client with HTTP transport -- Connecting to remote MCP servers -- Listing tools, resources, and prompts - -**Key Features:** -```php -$transport = new HttpTransport('http://localhost:8000'); -$client->connect($transport); - -$tools = $client->listTools(); -$resources = $client->listResources(); -$prompts = $client->listPrompts(); -``` - -**Usage:** -```bash -# Start the server first — the example picks its transport from the SAPI, -# so running it under a web server makes it speak Streamable HTTP -php -S localhost:8000 examples/server/discovery-calculator/server.php - -# Then run the client -php examples/client/http_discovery_calculator.php -``` - -### STDIO Client Communication - -**File**: `examples/client/stdio_client_communication.php` - -**What it demonstrates:** -- Server-to-client communication (logging, progress, sampling) -- Handling logging notifications from server -- Implementing sampling callbacks for LLM requests -- Progress tracking during tool execution - -**Key Features:** -```php -use Mcp\Client\Handler\Notification\LoggingNotificationHandler; -use Mcp\Client\Handler\Request\SamplingRequestHandler; -use Mcp\Client\Handler\Request\SamplingCallbackInterface; -use Mcp\Schema\ClientCapabilities; - -$loggingHandler = new LoggingNotificationHandler( - static function (LoggingMessageNotification $n) { - echo "[LOG {$n->level->value}] {$n->data}\n"; - } -); - -$samplingHandler = new SamplingRequestHandler(new class implements SamplingCallbackInterface { - public function __invoke(CreateSamplingMessageRequest $request): CreateSamplingMessageResult - { - // Perform LLM sampling and return result - } -}); - -$client = Client::builder() - ->setCapabilities(new ClientCapabilities(sampling: true)) - ->addNotificationHandler($loggingHandler) - ->addRequestHandler($samplingHandler) - ->build(); - -// Call tool with progress tracking -$result = $client->callTool( - name: 'run_dataset_quality_checks', - arguments: ['dataset' => 'customer_orders_2024'], - onProgress: static function (float $progress, ?float $total, ?string $message) { - $percent = $total > 0 ? round(($progress / $total) * 100) : '?'; - echo "[PROGRESS {$percent}%] {$message}\n"; - } -); -``` - -**Usage:** -```bash -# Run the client (automatically starts the communication server) -php examples/client/stdio_client_communication.php -``` - -### HTTP Client Communication +The example's header comments document the equivalent handshake-era conversation against the +same endpoint. [Serving both eras](run/protocol-eras.md) explains how the routing works. -**File**: `examples/client/http_client_communication.php` +## Modern-era client -**What it demonstrates:** -- Server-to-client communication over HTTP -- Receiving logging and progress notifications via SSE streaming -- Implementing sampling for HTTP-based servers -- Progress tracking with long-running operations +`stateless_lifecycle_client.php` drives the server above from PHP: it pins +`ProtocolVersion::V2026_07_28`, skips the handshake, discovers the server through +`server/discover` and calls a tool — one process, no session: -**Key Features:** -- Same client-side code as STDIO version -- Uses HttpTransport instead of StdioTransport -- Demonstrates SSE-based real-time notifications -- Shows HTTP session management - -**Usage:** -```bash -# Start the server -php -S 127.0.0.1:8000 examples/server/client-communication/server.php - -# Run the client -php examples/client/http_client_communication.php -``` - -!!! note - For sampling with HTTP transport, the server must support concurrent request processing (e.g., using Symfony CLI, PHP-FPM, or a production web server). PHP's built-in development server cannot handle the concurrent requests required for sampling. - -### STDIO Elicitation - -**File**: `examples/client/stdio_elicitation.php` - -**What it demonstrates:** -- Answering server-initiated `elicitation/create` requests -- Prompting interactively on STDIN, one field per requested property -- Deriving a default per schema type, applied when the user just presses Enter -- Casting the entered string back to the declared type - -Runs against the [Elicitation](#elicitation) server example, whose tools ask for -input mid-execution. - -**Key Features:** -```php -use Mcp\Client\Handler\Request\ElicitationCallbackInterface; -use Mcp\Client\Handler\Request\ElicitationRequestHandler; -use Mcp\Schema\ClientCapabilities; -use Mcp\Schema\Enum\ElicitAction; -use Mcp\Schema\Request\ElicitRequest; -use Mcp\Schema\Result\ElicitResult; - -$elicitationRequestHandler = new ElicitationRequestHandler(new class implements ElicitationCallbackInterface { - public function __invoke(ElicitRequest $request): ElicitResult - { - echo "\n[ELICIT] {$request->message}\n"; - - $content = []; - foreach ($request->requestedSchema->properties as $name => $definition) { - // defaultFor() and cast() below switch on the schema definition type - $default = $this->defaultFor($definition); - echo " {$definition->title} [{$default}]: "; - - $input = trim(fgets(\STDIN) ?: ''); - $content[$name] = '' === $input ? $default : $this->cast($definition, $input); - } - - return new ElicitResult(ElicitAction::Accept, $content); - } -}); - -$client = Client::builder() - ->setCapabilities(new ClientCapabilities(elicitation: true)) - ->addRequestHandler($elicitationRequestHandler) - ->build(); -``` - -**Usage:** -```bash -# Run the client (automatically starts the elicitation server) -php examples/client/stdio_elicitation.php -``` - -The client calls `book_restaurant` and `confirm_action`, so it prompts for a -multi-field reservation form and then for a boolean confirmation. - -### STDIO Roots - -**File**: `examples/client/stdio_roots.php` - -**What it demonstrates:** -- Advertising the `roots` capability during initialization -- Answering server `roots/list` requests with `file://` workspace folders -- Notifying the server when the list of roots changes - -**Key Features:** -```php -use Mcp\Client\Handler\Request\ListRootsRequestHandler; -use Mcp\Client\Handler\Request\RootsCallbackInterface; -use Mcp\Schema\ClientCapabilities; -use Mcp\Schema\Result\ListRootsResult; -use Mcp\Schema\Root; - -$rootsRequestHandler = new ListRootsRequestHandler(new class implements RootsCallbackInterface { - public function __invoke(ListRootsRequest $request): ListRootsResult - { - echo "[ROOTS] Server requested the client's list of roots\n"; - - return new ListRootsResult([ - new Root('file:///home/user/projects/app', 'Application'), - new Root('file:///home/user/projects/library', 'Library'), - ]); - } -}); - -$client = Client::builder() - ->setCapabilities(new ClientCapabilities(roots: true, rootsListChanged: true)) - ->addRequestHandler($rootsRequestHandler) - ->build(); - -// The tool asks the client for its roots, which triggers the handler above -$result = $client->callTool(name: 'inspect_workspace_roots'); - -// Whenever the workspace folders change -$client->sendRootsListChanged(); -``` - -**Usage:** -```bash -# Run the client (automatically starts the communication server) -php examples/client/stdio_roots.php -``` - -### Modern-era client - -**File**: `examples/client/stateless_lifecycle_client.php` - -**What it demonstrates:** -- Selecting protocol revision `2026-07-28` with a single `setProtocolVersion()` call -- A connection that sends no `initialize`, and asks `server/discover` only for the server's identity -- A [multi round-trip](handlers/input-required.md) call answered by the client, so the caller sees - one call and one result - -**Key Features:** -```php -$client = Client::builder() - ->setClientInfo('stateless-example-client', '1.0.0') - // The only line that selects the modern lifecycle. - ->setProtocolVersion(ProtocolVersion::V2026_07_28) - // Declared in the envelope of every request, so the server knows what it - // may ask for before it decides how to answer. - ->setCapabilities(new ClientCapabilities(elicitation: true)) - ->addRequestHandler($answerWithAName) - ->build(); - -$client->connect(new HttpTransport('http://127.0.0.1:8000/')); - -// One call from here. Two on the wire: the server returns its question, the -// handler above answers it, and the client retries carrying both the answer and -// the server's sealed `requestState`. -$client->callTool('greet', []); -``` - -Runs against the [2026-07-28 lifecycle](#the-2026-07-28-lifecycle) server example. - -**Usage:** ```bash -# Start the matching server first -php -S 127.0.0.1:8000 examples/server/stateless-lifecycle/server.php - -# Then run the client +php -S 127.0.0.1:8000 examples/server/stateless-lifecycle/server.php & php examples/client/stateless_lifecycle_client.php ``` -See [Clients on this revision](protocol-versions.md) for what that one builder line changes. +See [Clients on the modern revision](client/connecting.md) for the API it uses. diff --git a/examples/client/README.md b/examples/client/README.md index c2121719..0a6dd8ff 100644 --- a/examples/client/README.md +++ b/examples/client/README.md @@ -35,6 +35,25 @@ php -S 127.0.0.1:8000 examples/server/stateless-lifecycle/server.php php examples/client/stateless_lifecycle_client.php ``` +## Server-initiated requests + +The remaining examples answer requests the server sends back during a call: + +```bash +# Sampling, log and progress messages (also available over HTTP) +php examples/client/stdio_client_communication.php + +# Elicitation: answering the server's questions from an interactive prompt +php examples/client/stdio_elicitation.php + +# Roots: exposing workspace folders and signalling roots/list_changed +php examples/client/stdio_roots.php +``` + +> **Note**: `http_client_communication.php` needs a server that can answer a second request +> mid-call; PHP's built-in web server only does that with worker processes, e.g. +> `PHP_CLI_SERVER_WORKERS=2 php -S 127.0.0.1:8000 …`. + ## Requirements All examples require the server examples to be available. The STDIO examples spawn the server process, while the HTTP examples connect to a running HTTP server. diff --git a/examples/server/README.md b/examples/server/README.md index 779d8a43..8d60b4da 100644 --- a/examples/server/README.md +++ b/examples/server/README.md @@ -24,9 +24,9 @@ npx @modelcontextprotocol/inspector php examples/server/discovery-calculator/ser ## The 2026-07-28 lifecycle -`stateless-lifecycle/server.php` speaks protocol revision `2026-07-28`, which removed the `initialize` -handshake and protocol-level sessions. It is HTTP-only and cannot be driven by the Inspector, which -opens with `initialize`: +`stateless-lifecycle/server.php` demonstrates protocol revision `2026-07-28`, which removed the +`initialize` handshake and protocol-level sessions. Like every example it answers both eras — the +Inspector's `initialize` still works — but the interesting part is the modern one: ```bash php -S 127.0.0.1:8000 examples/server/stateless-lifecycle/server.php From 080a1bbe291bf1e2dd541ef790fbbe38781daaba Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Wed, 19 Aug 2026 23:57:55 +0200 Subject: [PATCH 12/13] [Docs] Fix the code samples and API listings flagged by the audit --- CHANGELOG.md | 2 +- docs/advanced/custom-handlers.md | 5 +- docs/advanced/extensions.md | 9 +- docs/client/capabilities.md | 4 + docs/client/connecting.md | 3 +- docs/client/server-requests.md | 173 +++++++++++++++-------------- docs/client/transports.md | 2 + docs/get-started/inspector.md | 2 +- docs/handlers/index.md | 9 +- docs/handlers/logging.md | 2 +- docs/protocol-versions.md | 3 - docs/run/authorization.md | 3 +- docs/run/framework-integration.md | 4 +- docs/run/http.md | 13 +-- docs/run/index.md | 2 +- docs/run/server-builder.md | 12 +- docs/run/stdio.md | 2 + docs/servers/prompts.md | 14 +-- docs/servers/registration.md | 9 +- docs/servers/resource-templates.md | 3 +- docs/servers/resources.md | 3 +- docs/servers/tools.md | 1 + 22 files changed, 156 insertions(+), 124 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 06280c6f..6e3cb3ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,7 +14,7 @@ All notable changes to `mcp/sdk` will be documented in this file. * Answer a request over a response stream under the 2026-07-28 lifecycle: `StatelessProtocol` runs handlers in a fiber, so `$gateway->progress()` and `$gateway->log()` work there as they do in the handshake era. The stream opens only if the handler actually emits something *and* the client's `Accept` admits `text/event-stream`, and the choice is made after the handler's first suspension, so a request that turns out to need `-32021` or `-32602` is still answered with the status the spec fixes for it. * Honour `io.modelcontextprotocol/logLevel` (SEP-2575), which replaced the `logging/setLevel` RPC: a request naming no level receives no `notifications/message` at all, one naming a level receives the messages at or above it. Adds `LoggingLevel::severity()` and `LoggingLevel::isAtLeast()`. * [BC Break] Answer a not-found subject with `-32602` (Invalid params) instead of `-32002`, which the 2026-07-28 revision reserves and forbids emitting (SEP-2164). `resources/read` picks the code from the revision serving the request — `-32602` with the uri in `error.data` from `2026-07-28` on, `-32002` below. `prompts/get` for an unknown prompt, `completion/complete` for an unknown reference and `tools/call` for an unknown tool switch to `-32602` in *every* revision: `-32002` was never the code for those. Adds `ProtocolVersion::usesInvalidParamsForResourceNotFound()`. -* Add the multi round-trip requests pattern for the 2026-07-28 lifecycle (SEP-2322): a `tools/call` or `prompts/get` handler returning `Mcp\Schema\Result\InputRequiredResult` comes back as `resultType: "input_required"` carrying the `inputRequests` it needs answered and an opaque `requestState`; the client retries the same request with `inputResponses`, which the handler reads through `RequestContext::getInputContext()`. `Mcp\Server\Stateless\RequestStateCodec` signs and time-bounds the state — set the key with `Builder::setRequestStateKey()`. +* Add the multi round-trip requests pattern for the 2026-07-28 lifecycle (SEP-2322): a `tools/call` or `prompts/get` handler returning `Mcp\Schema\Result\InputRequiredResult` comes back as `resultType: "input_required"` carrying the `inputRequests` it needs answered and an opaque `requestState`; the client retries the same request with `inputResponses`, which the handler reads through `RequestContext::getInputContext()`. `Mcp\Server\Stateless\RequestStateCodec` signs and time-bounds the state — set the key with `Builder::setRequestState()`. * Validate the standard MCP request headers under the 2026-07-28 lifecycle (SEP-2243): `Mcp\Server\Stateless\StandardHeaderValidator`, set with `Builder::setHeaderValidator()`, checks that `Mcp-Method` and `Mcp-Name` agree with the body they travel with and that a `Mcp-Param-*` mirrors the argument its tool marked `x-mcp-header`, answering `-32020` when they disagree. Intermediaries route on these headers, so a value contradicting the body has to be refused rather than ignored. * [BC Break] `Mcp\Schema\JsonRpc\Error` accepts `null` as its `$id`, and `getId()` may return it. An error response whose id could not be read now omits the member instead of sending `"id": ""` — which claimed the peer had issued a request with an empty-string id. All the `for*()` factories default to `null`, `fromArray()` accepts a missing or explicitly-null id, and `MessageFactory` decodes both as an id-less error rather than rejecting them. * Preserve the original request `id` on an invalid-but-parseable message (`-32600`) instead of answering it id-less: `InvalidInputMessageException` now carries the recoverable id via `getRequestId()`/`setRequestId()`, threaded from `MessageFactory` through to the error response. diff --git a/docs/advanced/custom-handlers.md b/docs/advanced/custom-handlers.md index 68a0ed04..a7fd28f9 100644 --- a/docs/advanced/custom-handlers.md +++ b/docs/advanced/custom-handlers.md @@ -15,7 +15,8 @@ Handle JSON-RPC requests (messages with an `id` that expect a response). Request `Response` or an `Error` object. Attach request handlers with `addRequestHandler()` (single) or `addRequestHandlers()` (multiple). You can call these -methods as many times as needed; each call prepends the handlers so they execute before the defaults: +methods as many times as needed; handlers are collected in call order, and the whole custom list runs before the +built-in defaults: ```php $server = Server::builder() @@ -93,5 +94,5 @@ interface NotificationHandlerInterface ## Example -Check out `examples/server/custom-method-handlers/server.php` for a complete example showing how to implement +Check out [`examples/server/custom-method-handlers/server.php`](https://github.com/modelcontextprotocol/php-sdk/blob/main/examples/server/custom-method-handlers/server.php) for a complete example showing how to implement custom `tools/list` and `tools/call` request handlers independently of the registry. diff --git a/docs/advanced/extensions.md b/docs/advanced/extensions.md index f87cf618..041f9862 100644 --- a/docs/advanced/extensions.md +++ b/docs/advanced/extensions.md @@ -1,7 +1,9 @@ # Protocol Extensions -MCP protocol extensions advertise additional, optional capabilities during the initialize handshake. -A server opts in via `Builder::enableExtension()`: +MCP protocol extensions advertise additional, optional capabilities alongside the regular ones — +during the `initialize` handshake, or on revision `2026-07-28` (which has no handshake) inside the +capabilities that travel with every request. A server opts in via `Builder::enableExtension()` and +the SDK places the advertisement correctly for whichever era the client speaks: ```php use Mcp\Schema\Extension\Apps\McpApps; @@ -19,7 +21,8 @@ be enabled in a single call. Enabling the same extension twice throws a Clients (hosts) advertise the extensions they support the same way, via `Client\Builder::enableExtension()`; the payload lands under -`capabilities.extensions` in the initialize request. +`capabilities.extensions` in the initialize request — or, on a modern revision, +under the client capabilities carried in each request's `_meta` envelope. > Note: extensions enabled via `enableExtension()` are merged into the > `extensions` capability even when you supply your own `ServerCapabilities` / diff --git a/docs/client/capabilities.md b/docs/client/capabilities.md index 54aa0df5..5ba082e7 100644 --- a/docs/client/capabilities.md +++ b/docs/client/capabilities.md @@ -24,6 +24,8 @@ if ($toolsResult->nextCursor) { ### Calling Tools ```php +use Mcp\Schema\Content\TextContent; + $result = $client->callTool( name: 'calculate', arguments: ['a' => 5, 'b' => 3, 'operation' => 'add'], @@ -80,6 +82,8 @@ foreach ($templatesResult->resourceTemplates as $template) { ### Reading Resources ```php +use Mcp\Schema\Content\{TextResourceContents, BlobResourceContents}; + $resourceResult = $client->readResource('config://app/settings'); foreach ($resourceResult->contents as $content) { diff --git a/docs/client/connecting.md b/docs/client/connecting.md index 6b434631..f78de808 100644 --- a/docs/client/connecting.md +++ b/docs/client/connecting.md @@ -61,7 +61,8 @@ $client = Client::builder() ### Protocol Version -Specify the MCP protocol version to offer during the handshake (defaults to the latest): +Specify the MCP protocol version to offer during the handshake (defaults to `V2025_11_25`, the +latest handshake revision — the modern `2026-07-28` revision must be chosen explicitly): ```php use Mcp\Schema\Enum\ProtocolVersion; diff --git a/docs/client/server-requests.md b/docs/client/server-requests.md index decd48fc..d25cd5c9 100644 --- a/docs/client/server-requests.md +++ b/docs/client/server-requests.md @@ -2,41 +2,81 @@ The client can receive requests and notifications from the server when configured with appropriate handlers. -## Logging Notifications - -> **Deprecated** since protocol revision `2026-07-28` ([SEP-2577](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577)), earliest removal `2027-07-28`. Logging keeps working until then; new integrations should log to stderr (stdio) or use OpenTelemetry instead. +## Elicitation (User Input Requests) -Receive structured log messages from the server: +Handle server requests to elicit additional information from the user during tool +execution. The server sends an `elicitation/create` request describing the fields it +needs; your callback presents them to the user and returns an `ElicitResult` with one of +three actions — accept (with the collected content), decline, or cancel: ```php -use Mcp\Client\Handler\Notification\LoggingNotificationHandler; -use Mcp\Schema\Notification\LoggingMessageNotification; -use Mcp\Schema\Enum\LoggingLevel; +use Mcp\Client\Handler\Request\ElicitationRequestHandler; +use Mcp\Client\Handler\Request\ElicitationCallbackInterface; +use Mcp\Exception\ElicitationException; +use Mcp\Schema\ClientCapabilities; +use Mcp\Schema\Enum\ElicitAction; +use Mcp\Schema\Enum\ElicitationMode; +use Mcp\Schema\Request\ElicitRequest; +use Mcp\Schema\Result\ElicitResult; -$loggingHandler = new LoggingNotificationHandler( - static function (LoggingMessageNotification $notification) { - // Route to your application's logging system - $level = $notification->level; - $message = $notification->data; - - match ($level) { - LoggingLevel::Debug => logger()->debug($message), - LoggingLevel::Info => logger()->info($message), - LoggingLevel::Warning => logger()->warning($message), - LoggingLevel::Error => logger()->error($message), - default => logger()->info($message), - }; +class ConsoleElicitationCallback implements ElicitationCallbackInterface +{ + public function __invoke(ElicitRequest $request): ElicitResult + { + echo $request->message.\PHP_EOL; + + // In url mode there is no schema to fill in — the user completes the + // interaction in the browser instead. + if (ElicitationMode::Url === $request->mode) { + echo 'Continue in your browser: '.$request->url.\PHP_EOL; + + return new ElicitResult(ElicitAction::Accept); + } + + // Form mode: present $request->requestedSchema->properties and collect input. + $content = []; + foreach ($request->requestedSchema->properties as $name => $definition) { + $answer = readline($definition->title.': '); + + if (false === $answer) { + // No input available — let the server know the user cancelled. + return new ElicitResult(ElicitAction::Cancel); + } + + $content[$name] = $answer; + } + + return new ElicitResult(ElicitAction::Accept, $content); } -); +} $client = Client::builder() - ->addNotificationHandler($loggingHandler) + ->setCapabilities(new ClientCapabilities(elicitation: true)) + ->addRequestHandler(new ElicitationRequestHandler(new ConsoleElicitationCallback)) ->build(); - -// Set minimum log level (optional) -$client->setLoggingLevel(LoggingLevel::Info); ``` +Return `new ElicitResult(ElicitAction::Decline)` when the user refuses to provide the +information, and `new ElicitResult(ElicitAction::Cancel)` when they dismiss the request. +Only the `Accept` action carries content. + +!!! warning + **Error Handling in Elicitation Callbacks:** + + - **Throw `ElicitationException`** to forward a specific error message to the server + - **Any other exception** is logged but returns a generic error to the server + + ```php + // Good: Server receives "No interactive console available" message + throw new ElicitationException('No interactive console available'); + + // Bad: Server receives generic "Error while processing elicitation" message + throw new \RuntimeException('No interactive console available'); + ``` + +See [`examples/client/stdio_elicitation.php`](https://github.com/modelcontextprotocol/php-sdk/blob/main/examples/client/stdio_elicitation.php) for a runnable example against the +elicitation demo server. + ## Sampling (LLM Requests) > **Deprecated** since protocol revision `2026-07-28` ([SEP-2577](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577)), earliest removal `2027-07-28`. Sampling keeps working until then; new integrations should call an LLM provider's API directly instead. @@ -142,71 +182,40 @@ blocks in a user message. The client should pass those blocks back to the LLM pr throw new \RuntimeException('Rate limit exceeded'); ``` -## Elicitation (User Input Requests) - -Handle server requests to elicit additional information from the user during tool -execution. The server sends an `elicitation/create` request describing the fields it -needs; your callback presents them to the user and returns an `ElicitResult` with one of -three actions — accept (with the collected content), decline, or cancel: - -```php -use Mcp\Client\Handler\Request\ElicitationRequestHandler; -use Mcp\Client\Handler\Request\ElicitationCallbackInterface; -use Mcp\Exception\ElicitationException; -use Mcp\Schema\ClientCapabilities; -use Mcp\Schema\Enum\ElicitAction; -use Mcp\Schema\Request\ElicitRequest; -use Mcp\Schema\Result\ElicitResult; - -class ConsoleElicitationCallback implements ElicitationCallbackInterface -{ - public function __invoke(ElicitRequest $request): ElicitResult - { - echo $request->message.\PHP_EOL; +## Logging Notifications - // Present $request->requestedSchema->properties to the user and collect input. - $content = []; - foreach ($request->requestedSchema->properties as $name => $definition) { - $answer = readline($definition->title.': '); +> **Deprecated** since protocol revision `2026-07-28` ([SEP-2577](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577)), earliest removal `2027-07-28`. Logging keeps working until then; new integrations should log to stderr (stdio) or use OpenTelemetry instead. - if (false === $answer) { - // No input available — let the server know the user cancelled. - return new ElicitResult(ElicitAction::Cancel); - } +Receive structured log messages from the server: - $content[$name] = $answer; - } +```php +use Mcp\Client\Handler\Notification\LoggingNotificationHandler; +use Mcp\Schema\Notification\LoggingMessageNotification; +use Mcp\Schema\Enum\LoggingLevel; - return new ElicitResult(ElicitAction::Accept, $content); +$loggingHandler = new LoggingNotificationHandler( + static function (LoggingMessageNotification $notification) { + // Route to your application's logging system + $level = $notification->level; + $message = $notification->data; + + match ($level) { + LoggingLevel::Debug => logger()->debug($message), + LoggingLevel::Info => logger()->info($message), + LoggingLevel::Warning => logger()->warning($message), + LoggingLevel::Error => logger()->error($message), + default => logger()->info($message), + }; } -} +); $client = Client::builder() - ->setCapabilities(new ClientCapabilities(elicitation: true)) - ->addRequestHandler(new ElicitationRequestHandler(new ConsoleElicitationCallback)) + ->addNotificationHandler($loggingHandler) ->build(); -``` - -Return `new ElicitResult(ElicitAction::Decline)` when the user refuses to provide the -information, and `new ElicitResult(ElicitAction::Cancel)` when they dismiss the request. -Only the `Accept` action carries content. - -!!! warning - **Error Handling in Elicitation Callbacks:** - - - **Throw `ElicitationException`** to forward a specific error message to the server - - **Any other exception** is logged but returns a generic error to the server - - ```php - // Good: Server receives "No interactive console available" message - throw new ElicitationException('No interactive console available'); - - // Bad: Server receives generic "Error while processing elicitation" message - throw new \RuntimeException('No interactive console available'); - ``` -See `examples/client/stdio_elicitation.php` for a runnable example against the -elicitation demo server. +// Set minimum log level (optional) +$client->setLoggingLevel(LoggingLevel::Info); +``` ## Roots @@ -251,6 +260,6 @@ throws a `RuntimeException`. On a client that is not connected it throws a $client->sendRootsListChanged(); ``` -See `examples/client/stdio_roots.php` for a runnable example: it calls the +See [`examples/client/stdio_roots.php`](https://github.com/modelcontextprotocol/php-sdk/blob/main/examples/client/stdio_roots.php) for a runnable example: it calls the `inspect_workspace_roots` tool of the client-communication demo server, which answers by issuing the `roots/list` request back to the client. diff --git a/docs/client/transports.md b/docs/client/transports.md index edfc131e..dd271661 100644 --- a/docs/client/transports.md +++ b/docs/client/transports.md @@ -23,6 +23,7 @@ $transport = new StdioTransport( - `cwd` (string|null): Working directory for the process - `env` (array|null): Environment variables - `logger` (LoggerInterface|null): Optional PSR-3 logger +- `maxBufferSize` (int): Maximum buffered bytes per message before the transport gives up ## HTTP Transport @@ -44,6 +45,7 @@ $transport = new HttpTransport( - `requestFactory` (RequestFactoryInterface|null): PSR-17 request factory (auto-discovered) - `streamFactory` (StreamFactoryInterface|null): PSR-17 stream factory (auto-discovered) - `logger` (LoggerInterface|null): Optional PSR-3 logger +- `maxSseBufferBytes` (int): Maximum buffered bytes for a streamed SSE response **PSR-18 Auto-Discovery:** diff --git a/docs/get-started/inspector.md b/docs/get-started/inspector.md index 0a3ea07c..27e146a8 100644 --- a/docs/get-started/inspector.md +++ b/docs/get-started/inspector.md @@ -60,7 +60,7 @@ npx @modelcontextprotocol/inspector http://localhost:8000 curl -X POST http://localhost:8000 \ -H "Content-Type: application/json" \ -H "Accept: application/json, text/event-stream" \ - -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","clientInfo":{"name":"test","version":"1.0.0"},"capabilities":{}}}' + -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","clientInfo":{"name":"test","version":"1.0.0"},"capabilities":{}}}' ``` ## Connect a real host diff --git a/docs/handlers/index.md b/docs/handlers/index.md index 6435287f..bb788141 100644 --- a/docs/handlers/index.md +++ b/docs/handlers/index.md @@ -16,11 +16,18 @@ public function summarize(string $text, RequestContext $context): string $result = $context->getClientGateway()->sample("Summarize:\n\n".$text, 500); - // `content` is TextContent|ImageContent|AudioContent + // `content` is TextContent|ImageContent|AudioContent (or ToolUseContent + // blocks when the client samples with tools) return $result->content instanceof TextContent ? $result->content->text : ''; } ``` +Two caveats on this example: clients drop log messages below `warning` unless they raise +the level first (see [Logging](logging.md)), and `sample()` belongs to the features +[deprecated by revision `2026-07-28`](../protocol-versions.md#deprecations) — on that +revision it throws, and [Asking for input](input-required.md) is the way to write it +instead. + * **[Talking back to the client](client-communication.md)** — the `ClientGateway`: asking the client's model for a completion (sampling), reporting progress on a long call, and sending notifications. diff --git a/docs/handlers/logging.md b/docs/handlers/logging.md index 1e61448c..1a10e50f 100644 --- a/docs/handlers/logging.md +++ b/docs/handlers/logging.md @@ -21,7 +21,7 @@ Level **warning** is the default level, so anything below it is dropped until th The SDK automatically injects a `RequestContext` instance into handlers. This can be used to create a `ClientLogger`. ```php -use Mcp\Capability\Logger\ClientLogger; +use Mcp\Capability\Attribute\McpTool; use Mcp\Server\RequestContext; #[McpTool] diff --git a/docs/protocol-versions.md b/docs/protocol-versions.md index 86a8d0b1..0ed48779 100644 --- a/docs/protocol-versions.md +++ b/docs/protocol-versions.md @@ -64,9 +64,6 @@ would leave a connection neither side could use. A client speaking it never gets of an `initialize` request, and the transport routes it to the modern dispatcher before any negotiation is attempted. See [Serving both eras](run/protocol-eras.md). -This table is mirrored by the `provideNegotiationTable()` data provider in -`tests/Unit/Server/Handler/Request/InitializeHandlerTest.php`, which drives its supported-revision rows off the enum so -a newly declared revision is covered automatically. ### Pinning a revision diff --git a/docs/run/authorization.md b/docs/run/authorization.md index 0a329277..0985656a 100644 --- a/docs/run/authorization.md +++ b/docs/run/authorization.md @@ -50,6 +50,7 @@ Authorization in MCP is implemented at the transport level using PSR-15 middlewa ```php use Mcp\Server; use Mcp\Server\Transport\Http\Middleware\AuthorizationMiddleware; +use Mcp\Server\Transport\Http\Middleware\OAuthRequestMetaMiddleware; use Mcp\Server\Transport\Http\Middleware\ProtectedResourceMetadataMiddleware; use Mcp\Server\Transport\Http\OAuth\JwksProvider; use Mcp\Server\Transport\Http\OAuth\JwtTokenValidator; @@ -100,7 +101,7 @@ $transport = new StreamableHttpTransport( // 6. Run server $server = Server::builder() ->setServerInfo('Protected MCP Server', '1.0.0') - ->setDiscovery(__DIR__) + ->setDiscovery(__DIR__, excludeDirs: ['vendor']) ->build(); $response = $server->run($transport); diff --git a/docs/run/framework-integration.md b/docs/run/framework-integration.md index 95457a0c..f461773f 100644 --- a/docs/run/framework-integration.md +++ b/docs/run/framework-integration.md @@ -30,7 +30,7 @@ $request = $psr17Factory->createServerRequestFromGlobals(); $server = Server::builder() ->setServerInfo('HTTP Server', '1.0.0') - ->setDiscovery(__DIR__, ['.']) + ->setDiscovery(__DIR__, ['.'], excludeDirs: ['vendor']) ->setSession(new FileSessionStore(__DIR__ . '/sessions')) // HTTP needs persistent sessions ->build(); @@ -137,7 +137,7 @@ $app = AppFactory::create(); $app->any('/mcp', function ($request, $response) { $server = Server::builder() ->setServerInfo('My MCP Server', '1.0.0') - ->setDiscovery(__DIR__, ['.']) + ->setDiscovery(__DIR__, ['.'], excludeDirs: ['vendor']) ->build(); $transport = new StreamableHttpTransport($request); diff --git a/docs/run/http.md b/docs/run/http.md index 1f818207..a57a8dbe 100644 --- a/docs/run/http.md +++ b/docs/run/http.md @@ -182,15 +182,10 @@ exhaust memory. A value below `1` throws `InvalidArgumentException`. ## JSON-RPC Batch Size Limit -A JSON-RPC batch (top-level array) is capped at 100 messages by default. Oversized batches are rejected before any -message is constructed, so a single small request cannot amplify into arbitrarily many operations. The cap lives on -`MessageFactory`: - -```php -use Mcp\JsonRpc\MessageFactory; - -$factory = MessageFactory::make(maxBatchSize: 50); -``` +A JSON-RPC batch (top-level array) is capped at 100 messages. Oversized batches are rejected before any +message is constructed, so a single small request cannot amplify into arbitrarily many operations. The cap +lives on `Mcp\JsonRpc\MessageFactory` and is not currently configurable through the builder — a server built +with `Server::builder()` always uses the default of 100. Single-message vs batch is determined from the decoded JSON type — a JSON object is a single message, a JSON array is a batch. Scalars, empty payloads, and non-object batch elements are returned as `InvalidInputMessageException` diff --git a/docs/run/index.md b/docs/run/index.md index d3a64e77..208f22a7 100644 --- a/docs/run/index.md +++ b/docs/run/index.md @@ -6,7 +6,7 @@ answering. Every transport implements `TransportInterface` and is used the same ```php $server = Server::builder() ->setServerInfo('My Server', '1.0.0') - ->setDiscovery(__DIR__, ['.']) + ->setDiscovery(__DIR__, ['.'], excludeDirs: ['vendor']) ->build(); $transport = new SomeTransport(); diff --git a/docs/run/server-builder.md b/docs/run/server-builder.md index 04620c6d..52d27e7c 100644 --- a/docs/run/server-builder.md +++ b/docs/run/server-builder.md @@ -15,7 +15,7 @@ use Mcp\Server; $server = Server::builder() ->setServerInfo('My MCP Server', '1.0.0') - ->setDiscovery(__DIR__, ['.']) + ->setDiscovery(__DIR__, ['.'], excludeDirs: ['vendor']) ->build(); ``` @@ -26,7 +26,7 @@ use Mcp\Server\Builder; $server = (new Builder()) ->setServerInfo('My MCP Server', '1.0.0') - ->setDiscovery(__DIR__, ['.']) + ->setDiscovery(__DIR__, ['.'], excludeDirs: ['vendor']) ->build(); ``` @@ -59,6 +59,7 @@ $server = Server::builder() - `$description` (string|null): Optional description - `$icons` (Icon[]|null): Optional array of server icons - `$websiteUrl` (string|null): Optional server website URL +- `$title` (string|null): Optional human-readable display title, distinct from `$name` ### Pagination Limit @@ -160,10 +161,15 @@ $server = Server::builder() **Basic Discovery (scans current directory and `src/`):** ```php $server = Server::builder() - ->setDiscovery(__DIR__) // Minimal setup + ->setDiscovery(__DIR__, excludeDirs: ['vendor']) // Scans '.' and 'src' ->build(); ``` +!!! warning + Always exclude `vendor/` when the scanned directories contain it. The recursive scan + tries to load every class it finds, and a single file that cannot be loaded makes the + scan abort — the server then reports an **empty** element list instead of an error. + **Production Setup with Caching:** ```php use Symfony\Component\Cache\Adapter\FilesystemAdapter; diff --git a/docs/run/stdio.md b/docs/run/stdio.md index 24d40302..2f6ccca7 100644 --- a/docs/run/stdio.md +++ b/docs/run/stdio.md @@ -15,6 +15,8 @@ $transport = new StdioTransport( - **`input`** (optional): Input stream resource. Defaults to `STDIN`. - **`output`** (optional): Output stream resource. Defaults to `STDOUT`. - **`logger`** (optional): `LoggerInterface` - PSR-3 logger for debugging. Defaults to `NullLogger`. +- **`runnerControl`** (optional): `RunnerControlInterface` - controls the read loop; the default runs until the input stream closes. +- **`maxLineBytes`** (optional): Maximum accepted line length in bytes. Oversized lines are rejected as invalid messages. !!! warning When using STDIO transport, **never** write to `STDOUT` in your handlers as it's reserved for JSON-RPC communication. diff --git a/docs/servers/prompts.md b/docs/servers/prompts.md index e5fb4a61..33da3c7c 100644 --- a/docs/servers/prompts.md +++ b/docs/servers/prompts.md @@ -25,7 +25,7 @@ class PromptGenerator - **`name`** (optional): Prompt identifier. Defaults to method name if not provided. - **`title`** (optional): Human-readable display title shown in client UI. Distinct from `name`. -- **`description`** (optional): Prompt description. Defaults to docblock summary if not provided. +- **`description`** (optional): Prompt description. Falls back to the docblock (summary plus long description). - **`icons`** (optional): Array of `Icon` objects for visual representation. - **`meta`** (optional): Arbitrary key-value pairs for custom metadata. @@ -62,19 +62,15 @@ public function userAssistantFormat(): array ]; } -// Mixed content types in messages +// Non-text content — each message carries exactly one content block, +// so an image goes into its own message use Mcp\Schema\Content\{TextContent, ImageContent}; public function mixedContent(): array { return [ - [ - 'role' => 'user', - 'content' => [ - new TextContent('Analyze this image:'), - new ImageContent(data: $imageData, mimeType: 'image/png') - ] - ] + ['role' => 'user', 'content' => new TextContent('Analyze this image:')], + ['role' => 'user', 'content' => new ImageContent(data: $imageData, mimeType: 'image/png')] ]; } diff --git a/docs/servers/registration.md b/docs/servers/registration.md index 9a5c0b00..e2dc563c 100644 --- a/docs/servers/registration.md +++ b/docs/servers/registration.md @@ -15,7 +15,7 @@ registry somehow. There are three ways to get it there, and they mix freely. **Example:** ```php $server = Server::builder() - ->setDiscovery(__DIR__, ['.']) // Automatic discovery + ->setDiscovery(__DIR__, ['.'], excludeDirs: ['vendor']) // Automatic discovery ->build(); ``` @@ -88,6 +88,7 @@ $server = Server::builder() - `inputSchema` (array|null): Optional input schema for the tool - `icons` (Icon[]|null): Optional array of icons for the tool - `meta` (array|null): Optional metadata for the tool +- `outputSchema` (array|null): Optional JSON schema describing the tool's `structuredContent` ### Manual Resource Registration @@ -109,6 +110,7 @@ $server = Server::builder() - `handler` (callable|string): The resource handler - `uri` (string): The resource URI - `name` (string|null): Optional resource name +- `title` (string|null): Optional human-readable title for display in UI - `description` (string|null): Optional resource description - `mimeType` (string|null): Optional MIME type of the resource - `size` (int|null): Optional size of the resource in bytes @@ -136,9 +138,11 @@ $server = Server::builder() - `handler` (callable|string): The resource template handler - `uriTemplate` (string): The resource URI template - `name` (string|null): Optional resource template name +- `title` (string|null): Optional human-readable title for display in UI - `description` (string|null): Optional resource template description - `mimeType` (string|null): Optional MIME type of the resource - `annotations` (Annotations|null): Optional annotations for the resource template +- `meta` (array|null): Optional metadata for the resource template ### Manual Prompt Registration @@ -160,6 +164,7 @@ $server = Server::builder() - `title` (string|null): Optional human-readable title for display in UI - `description` (string|null): Optional prompt description - `icons` (Icon[]|null): Optional array of icons for the prompt +- `meta` (array|null): Optional metadata for the prompt **Note:** `name` and `description` are optional when the handler is a method or an invokable class — they are then derived from the method name and its docblock. A **closure** handler has neither, so it gets a generated name @@ -230,7 +235,7 @@ Combine both methods for maximum flexibility: ```php $server = Server::builder() - ->setDiscovery(__DIR__, ['.']) // Discover most capabilities + ->setDiscovery(__DIR__, ['.'], excludeDirs: ['vendor']) // Discover most capabilities ->addTool([ExternalService::class, 'process'], 'external') // Add specific ones ->build(); ``` diff --git a/docs/servers/resource-templates.md b/docs/servers/resource-templates.md index ab6867c4..2cff71e4 100644 --- a/docs/servers/resource-templates.md +++ b/docs/servers/resource-templates.md @@ -34,9 +34,10 @@ class UserProvider - **`uriTemplate`** (required): URI with `{variables}`. Must start with a scheme (`file://`, `user://`, …) and contain at least one variable. - **`name`** (optional): Short resource template identifier. Defaults to method name if not provided. - **`title`** (optional): Human-readable display title shown in client UI. Distinct from `name`. -- **`description`** (optional): Template description. Defaults to docblock summary if not provided. +- **`description`** (optional): Template description. Falls back to the docblock (summary plus long description). - **`mimeType`** (optional): MIME type of the resource content. - **`annotations`** (optional): Additional metadata. +- **`meta`** (optional): Arbitrary key-value pairs for custom metadata. ## Variable Rules diff --git a/docs/servers/resources.md b/docs/servers/resources.md index 807a19cd..10736c48 100644 --- a/docs/servers/resources.md +++ b/docs/servers/resources.md @@ -27,7 +27,7 @@ class ConfigProvider - **`uri`** (required): Unique resource identifier. Must comply with [RFC 3986](https://datatracker.ietf.org/doc/html/rfc3986). - **`name`** (optional): Short resource identifier. Defaults to method name if not provided. - **`title`** (optional): Human-readable display title shown in client UI. Distinct from `name`. -- **`description`** (optional): Resource description. Defaults to docblock summary if not provided. +- **`description`** (optional): Resource description. Falls back to the docblock (summary plus long description). - **`mimeType`** (optional): MIME type of the resource content. - **`size`** (optional): Size in bytes if known. - **`annotations`** (optional): Additional metadata. @@ -128,6 +128,7 @@ Resource handlers can throw any exception, but the type determines how it's hand - **Any other exception**: Converted to JSON-RPC error response, but with a generic error message ```php +use Mcp\Capability\Attribute\McpResourceTemplate; use Mcp\Exception\ResourceReadException; // A URI with variables is a resource *template*; `#[McpResource]` registers a diff --git a/docs/servers/tools.md b/docs/servers/tools.md index b8a40979..a919b401 100644 --- a/docs/servers/tools.md +++ b/docs/servers/tools.md @@ -30,6 +30,7 @@ class Calculator - **`title`** (optional): Human-readable display title shown in client UI. Distinct from `name`. - **`description`** (optional): Tool description. Falls back to the docblock (summary plus long description); stays unset if there is no docblock. - **`annotations`** (optional): `ToolAnnotations` object for additional metadata. +- **`outputSchema`** (optional): JSON schema describing `structuredContent`; see [Structured Output](#structured-output). - **`icons`** (optional): Array of `Icon` objects for visual representation. - **`meta`** (optional): Arbitrary key-value pairs for custom metadata. From d8996bec2c03f66559db3b79d3023a3314b4cbee Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Wed, 19 Aug 2026 23:59:02 +0200 Subject: [PATCH 13/13] [Docs] Link the negotiation spec section at latest, not draft --- docs/client/connecting.md | 2 +- docs/protocol-versions.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/client/connecting.md b/docs/client/connecting.md index f78de808..b060916c 100644 --- a/docs/client/connecting.md +++ b/docs/client/connecting.md @@ -74,7 +74,7 @@ $client = Client::builder() This is an offer, not a demand. A server that does not support the requested revision counter-offers one it does, as described in the specification's -[protocol version negotiation](https://modelcontextprotocol.io/specification/draft/basic/versioning#protocol-version-negotiation) +[protocol version negotiation](https://modelcontextprotocol.io/specification/latest/basic/versioning#protocol-version-negotiation) section. The client accepts any counter-offer it knows about and continues on that revision; a counter-offer the SDK cannot speak fails the handshake with a `ConnectionException` rather than continuing on a revision neither side agreed on. Use `$client->getProtocolVersion()` after connecting to read what was actually negotiated. diff --git a/docs/protocol-versions.md b/docs/protocol-versions.md index 0ed48779..09081bab 100644 --- a/docs/protocol-versions.md +++ b/docs/protocol-versions.md @@ -30,7 +30,7 @@ map; the mechanics live with the task they belong to. Revisions up to `2025-11-25` agree on one during `initialize`. The client names the revision it wants to speak, and the server answers with the revision the connection will actually use. Both sides disconnect if they cannot agree. This follows the -[protocol version negotiation](https://modelcontextprotocol.io/specification/draft/basic/versioning#protocol-version-negotiation) +[protocol version negotiation](https://modelcontextprotocol.io/specification/latest/basic/versioning#protocol-version-negotiation) section of the specification. The modern era negotiates nothing — each request names its own revision. The SDK's known revisions live in `Mcp\Schema\Enum\ProtocolVersion`, declared oldest to newest: