diff --git a/.gitattributes b/.gitattributes
deleted file mode 100644
index 27224aea..00000000
--- a/.gitattributes
+++ /dev/null
@@ -1,10 +0,0 @@
-/.github export-ignore
-/.phpdoc export-ignore
-/docs export-ignore
-/examples export-ignore
-/tests export-ignore
-/.php-cs-fixer.dist.php export-ignore
-/Makefile export-ignore
-/phpdoc.dist.xml
-/phpstan* export-ignore
-/phpunit.xml.dist export-ignore
diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS
deleted file mode 100644
index 29e79b5b..00000000
--- a/.github/CODEOWNERS
+++ /dev/null
@@ -1 +0,0 @@
-* @chr-hertel @Nyholm @CodeWithKyrian @soyuka
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
deleted file mode 100644
index a6fc01e5..00000000
--- a/.github/dependabot.yml
+++ /dev/null
@@ -1,11 +0,0 @@
-version: 2
-updates:
- - package-ecosystem: composer
- directory: /
- schedule:
- interval: weekly
-
- - package-ecosystem: github-actions
- directory: /
- schedule:
- interval: weekly
diff --git a/.github/workflows/conformance-weekly.yaml b/.github/workflows/conformance-weekly.yaml
deleted file mode 100644
index f12f3792..00000000
--- a/.github/workflows/conformance-weekly.yaml
+++ /dev/null
@@ -1,162 +0,0 @@
-name: conformance-weekly
-
-# Runs the MCP conformance suite weekly against the latest
-# @modelcontextprotocol/conformance release. The on:pull_request pipeline
-# pins to whatever version is available at PR time; this schedule catches
-# upstream releases that add scenarios between PRs.
-#
-# It also scores each run and publishes the client/server pass-rate as
-# shields.io endpoint JSON to the orphan `badges` branch (consumed by the
-# README); that branch is created on the first run.
-on:
- schedule:
- - cron: '0 6 * * 1' # Mondays 06:00 UTC
- workflow_dispatch:
-
-permissions:
- contents: write
- issues: write
-
-jobs:
- server:
- name: conformance / server (latest)
- runs-on: ubuntu-latest
- steps:
- - uses: actions/checkout@v7
- - uses: actions/setup-node@v7
- with:
- node-version: '22'
- - run: composer install --prefer-dist --no-progress --no-interaction
- - name: Start conformance server
- run: |
- mkdir -p tests/Conformance/sessions tests/Conformance/logs
- chmod -R 777 tests/Conformance/sessions tests/Conformance/logs
- docker compose -f tests/Conformance/Fixtures/docker-compose.yml up -d
- sleep 5
- - name: Run conformance tests
- working-directory: ./tests/Conformance
- run: npx --yes @modelcontextprotocol/conformance@latest server --url http://localhost:8000/ --expected-failures conformance-baseline.yml --output-dir results
- - name: Generate score badge
- if: always()
- run: php tests/Conformance/score.php server
- - name: Show docker logs on failure
- if: failure()
- run: docker compose -f tests/Conformance/Fixtures/docker-compose.yml logs
- - name: Upload conformance results
- if: failure()
- uses: actions/upload-artifact@v7
- with:
- name: conformance-server-results
- path: |
- tests/Conformance/logs
- tests/Conformance/results
- - name: Upload score badge
- if: always()
- uses: actions/upload-artifact@v7
- with:
- name: server-badge
- path: tests/Conformance/server-conformance.json
-
- client:
- name: conformance / client (latest)
- runs-on: ubuntu-latest
- steps:
- - uses: actions/checkout@v7
- - uses: shivammathur/setup-php@v2
- with:
- php-version: '8.4'
- coverage: none
- - uses: actions/setup-node@v7
- with:
- node-version: '22'
- - run: composer install --prefer-dist --no-progress --no-interaction
- - run: mkdir -p tests/Conformance/logs
- - name: Run conformance tests
- working-directory: ./tests/Conformance
- run: npx --yes @modelcontextprotocol/conformance@latest client --command "php ${{ github.workspace }}/tests/Conformance/client.php" --suite all --expected-failures conformance-baseline.yml --output-dir results
- - name: Generate score badge
- if: always()
- run: php tests/Conformance/score.php client
- - name: Upload conformance results
- if: failure()
- uses: actions/upload-artifact@v7
- with:
- name: conformance-client-results
- path: |
- tests/Conformance/logs
- tests/Conformance/results
- - name: Upload score badge
- if: always()
- uses: actions/upload-artifact@v7
- with:
- name: client-badge
- path: tests/Conformance/client-conformance.json
-
- notify:
- name: Open issue on failure
- runs-on: ubuntu-latest
- needs: [server, client]
- if: failure() && github.event_name == 'schedule'
- env:
- GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- GH_REPO: ${{ github.repository }}
- RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
- steps:
- - name: File or comment tracking issue
- run: |
- existing=$(gh issue list --label conformance-weekly --state open --json number --jq '.[0].number // empty')
- if [ -n "$existing" ]; then
- gh issue comment "$existing" --body "New failure on $(date -u +%FT%TZ): $RUN_URL"
- else
- gh issue create \
- --title '[conformance] Weekly conformance run failed' \
- --label conformance-weekly \
- --body "Weekly conformance against \`@modelcontextprotocol/conformance@latest\` failed.
-
- - Run: $RUN_URL
- - Triggered: $(date -u +%FT%TZ)
-
- Upstream likely published a release whose scenarios the SDK does not satisfy. Either fix the SDK, update the conformance fixtures, or add the new failure to \`tests/Conformance/conformance-baseline.yml\`."
- fi
-
- publish:
- name: Publish conformance badges
- runs-on: ubuntu-latest
- needs: [server, client]
- # Publish even when the suite regressed (the badge should reflect reality);
- # skip on forks, which cannot push the `badges` branch.
- if: ${{ !cancelled() && github.repository == 'modelcontextprotocol/php-sdk' }}
- steps:
- - uses: actions/checkout@v7
- - uses: actions/download-artifact@v8
- with:
- name: server-badge
- path: badges-in
- - uses: actions/download-artifact@v8
- with:
- name: client-badge
- path: badges-in
- - name: Publish to badges branch
- run: |
- git config user.name "github-actions[bot]"
- git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
-
- if git ls-remote --exit-code --heads origin badges >/dev/null 2>&1; then
- git fetch origin badges
- git worktree add badges-wt badges
- else
- git worktree add --detach badges-wt
- git -C badges-wt checkout --orphan badges
- git -C badges-wt rm -rf --quiet . >/dev/null 2>&1 || true
- fi
-
- cp badges-in/server-conformance.json badges-in/client-conformance.json badges-wt/
-
- cd badges-wt
- git add -A
- if git diff --cached --quiet; then
- echo "Conformance scores unchanged."
- else
- git commit -m "Update conformance score badges"
- git push origin badges
- fi
diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml
deleted file mode 100644
index a1c3c832..00000000
--- a/.github/workflows/docs.yml
+++ /dev/null
@@ -1,36 +0,0 @@
-name: Deploy Documentation
-
-on:
- release:
- types: [published]
- workflow_dispatch:
-
-permissions:
- contents: write
-
-jobs:
- deploy:
- runs-on: ubuntu-latest
- steps:
- - name: Checkout
- uses: actions/checkout@v7
-
- - name: Setup PHP
- uses: shivammathur/setup-php@v2
- with:
- php-version: '8.4'
- coverage: "none"
-
- - name: Install Composer
- uses: "ramsey/composer-install@v4"
-
- - name: Generate Documentation
- run: make docs
-
- - name: Deploy to gh-pages branch
- uses: peaceiris/actions-gh-pages@v4
- with:
- github_token: ${{ secrets.GITHUB_TOKEN }}
- publish_dir: ./.phpdoc/build
- enable_jekyll: false
- cname: php.sdk.modelcontextprotocol.io
diff --git a/.github/workflows/pipeline.yaml b/.github/workflows/pipeline.yaml
deleted file mode 100644
index f7396e60..00000000
--- a/.github/workflows/pipeline.yaml
+++ /dev/null
@@ -1,214 +0,0 @@
-name: pipeline
-on: pull_request
-
-permissions:
- contents: read
- pull-requests: write
-
-jobs:
- unit:
- runs-on: ubuntu-latest
- strategy:
- matrix:
- php: ['8.1', '8.2', '8.3', '8.4', '8.5']
- dependencies: ['lowest', 'highest']
- symfony-version: ['']
- include:
- - symfony-version: '5.4.*'
- dependencies: 'highest'
- php: '8.2'
- - symfony-version: '6.4.*'
- dependencies: 'highest'
- php: '8.2'
- - symfony-version: '7.4.*'
- dependencies: 'highest'
- php: '8.4'
- - symfony-version: '8.0.*'
- dependencies: 'highest'
- php: '8.4'
-
- env:
- SYMFONY_REQUIRE: ${{ matrix.symfony-version || '>=6.4' }}
-
- steps:
- - name: Checkout
- uses: actions/checkout@v7
-
- - name: Setup PHP
- uses: shivammathur/setup-php@v2
- with:
- php-version: ${{ matrix.php }}
- tools: flex
- coverage: "none"
-
- - name: Install Composer
- uses: "ramsey/composer-install@v4"
- with:
- dependency-versions: "${{ matrix.dependencies }}"
-
- - name: Tests
- run: vendor/bin/phpunit --testsuite=unit
-
- integration:
- runs-on: ubuntu-latest
- strategy:
- matrix:
- php: ['8.1', '8.2', '8.3', '8.4', '8.5']
-
- steps:
- - name: Checkout
- uses: actions/checkout@v7
-
- - name: Setup PHP
- uses: shivammathur/setup-php@v2
- with:
- php-version: ${{ matrix.php }}
- coverage: "none"
-
- - name: Install Composer
- uses: "ramsey/composer-install@v4"
-
- - name: Tests
- run: vendor/bin/phpunit --testsuite=integration
-
- inspector:
- runs-on: ubuntu-latest
- steps:
- - name: Checkout
- uses: actions/checkout@v7
-
- - name: Setup PHP
- uses: shivammathur/setup-php@v2
- with:
- php-version: '8.4'
- coverage: "none"
-
- - name: Setup Node
- uses: actions/setup-node@v7
- with:
- node-version: '22'
-
- - name: Install Composer
- uses: "ramsey/composer-install@v4"
-
- - name: Tests
- run: vendor/bin/phpunit --testsuite=inspector
-
- conformance-server:
- name: conformance / server
- runs-on: ubuntu-latest
- steps:
- - name: Checkout
- uses: actions/checkout@v7
-
- - name: Setup Node
- uses: actions/setup-node@v7
- with:
- node-version: '22'
-
- - name: Install Composer
- uses: "ramsey/composer-install@v4"
-
- - name: Start conformance server
- run: |
- mkdir -p tests/Conformance/sessions tests/Conformance/logs
- chmod -R 777 tests/Conformance/sessions tests/Conformance/logs
- docker compose -f tests/Conformance/Fixtures/docker-compose.yml up -d
- sleep 5
-
- - name: Run conformance tests
- working-directory: ./tests/Conformance
- run: npx @modelcontextprotocol/conformance server --url http://localhost:8000/ --expected-failures conformance-baseline.yml
-
- - name: Show logs on failure
- if: failure()
- run: |
- echo "=== Docker Compose Logs ==="
- docker compose -f tests/Conformance/Fixtures/docker-compose.yml logs
- echo ""
- echo "=== Conformance Log ==="
- cat tests/Conformance/logs/conformance.log 2>/dev/null || echo "No conformance log found"
- echo ""
- echo "=== Test Results (first failed test) ==="
- find tests/Conformance/results -name "checks.json" 2>/dev/null | head -3 | while read f; do
- echo "--- $f ---"
- cat "$f"
- echo ""
- done || echo "No results found"
- echo ""
- echo "=== Directory permissions ==="
- ls -la tests/Conformance/
- ls -la tests/Conformance/logs/ 2>/dev/null || echo "logs dir issue"
- ls -la tests/Conformance/sessions/ 2>/dev/null || echo "sessions dir issue"
-
- - name: Cleanup
- if: always()
- run: docker compose -f tests/Conformance/Fixtures/docker-compose.yml down
-
- conformance-client:
- name: conformance / client
- runs-on: ubuntu-latest
- steps:
- - name: Checkout
- uses: actions/checkout@v7
-
- - name: Setup PHP
- uses: shivammathur/setup-php@v2
- with:
- php-version: '8.4'
- coverage: "none"
-
- - name: Setup Node
- uses: actions/setup-node@v7
- with:
- node-version: '22'
-
- - name: Install Composer
- uses: "ramsey/composer-install@v4"
-
- - name: Create log directory
- run: mkdir -p tests/Conformance/logs
-
- - name: Run client conformance tests
- working-directory: ./tests/Conformance
- run: npx @modelcontextprotocol/conformance client --command "php ${{ github.workspace }}/tests/Conformance/client.php" --suite all --expected-failures conformance-baseline.yml
-
- - name: Show logs on failure
- if: failure()
- run: |
- echo "=== Client Conformance Log ==="
- cat tests/Conformance/logs/client-conformance.log 2>/dev/null || echo "No client conformance log found"
- echo ""
- echo "=== Test Results ==="
- find tests/Conformance/results -name "checks.json" 2>/dev/null | head -3 | while read f; do
- echo "--- $f ---"
- cat "$f"
- echo ""
- done || echo "No results found"
-
- qa:
- runs-on: ubuntu-latest
- steps:
- - name: Checkout
- uses: actions/checkout@v7
-
- - name: Setup PHP
- uses: shivammathur/setup-php@v2
- with:
- php-version: '8.1'
- coverage: "none"
-
- - name: Composer Validation
- run: composer validate --strict
-
- - name: Install Composer
- uses: "ramsey/composer-install@v4"
-
- - name: Code Style PHP
- run: vendor/bin/php-cs-fixer fix --dry-run
-
- - name: PHPStan
- run: vendor/bin/phpstan analyse
-
- - name: Documentation
- run: make docs
diff --git a/.gitignore b/.gitignore
deleted file mode 100644
index 5ea477c0..00000000
--- a/.gitignore
+++ /dev/null
@@ -1,17 +0,0 @@
-.phpunit.cache
-.php-cs-fixer.cache
-composer.lock
-coverage
-vendor
-examples/**/dev.log
-examples/**/cache
-examples/**/sessions
-tests/Conformance/client-conformance.json
-tests/Conformance/server-conformance.json
-tests/Conformance/results
-tests/Conformance/sessions
-tests/Conformance/logs/*.log
-
-# phpDocumentor
-.phpdoc/build/
-.phpdoc/cache/
diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php
deleted file mode 100644
index a424acee..00000000
--- a/.php-cs-fixer.dist.php
+++ /dev/null
@@ -1,39 +0,0 @@
-setParallelConfig(ParallelConfigFactory::detect())
- ->setRules([
- '@Symfony' => true,
- '@Symfony:risky' => true,
- 'header_comment' => ['header' => $fileHeader],
- 'php_unit_test_case_static_method_calls' => ['call_type' => 'this'],
- ])
- ->setRiskyAllowed(true)
- ->setFinder((new Finder())->in(__DIR__))
-;
diff --git a/.phpdoc/template/base.html.twig b/.phpdoc/template/base.html.twig
deleted file mode 100644
index 760f1652..00000000
--- a/.phpdoc/template/base.html.twig
+++ /dev/null
@@ -1,13 +0,0 @@
-{% extends 'layout.html.twig' %}
-
-{% set topMenu = {
- "menu": [
- { "name": "Guides", "url": "docs/index.html"},
- { "name": "Specification", "url": "https://modelcontextprotocol.io/" }
- ],
- "social": [
- { "iconClass": "fab fa-github", "url": "https://github.com/modelcontextprotocol/php-sdk"},
- { "iconClass": "fab fa-discord", "url": "https://discord.gg/6CSzBmMkjX"}
- ]
-}
-%}
diff --git a/.phpdoc/template/components/header-title.html.twig b/.phpdoc/template/components/header-title.html.twig
deleted file mode 100644
index fe8d091f..00000000
--- a/.phpdoc/template/components/header-title.html.twig
+++ /dev/null
@@ -1,10 +0,0 @@
-
diff --git a/CHANGELOG.md b/CHANGELOG.md
deleted file mode 100644
index 67f449b5..00000000
--- a/CHANGELOG.md
+++ /dev/null
@@ -1,124 +0,0 @@
-# Changelog
-
-All notable changes to `mcp/sdk` will be documented in this file.
-
-0.8.0
------
-
-* Always emit `{}` for empty tool schemas: `Tool` recursively normalizes every empty sub-schema — `properties`, `items`, `additionalProperties`, `$defs`, combinators and the other draft-07 to 2020-12 schema keywords — in the constructor, for both `inputSchema` and `outputSchema`, so an object position is never serialized as `[]`.
-* Prompt generators returning content as typed arrays (`['type' => 'text', ...]` etc.) no longer lose the optional fields: `annotations` on every content type, and `_meta` and an explicit `mimeType` on embedded resource contents, now carry through to the resulting `PromptMessage` instead of being silently dropped. A missing resource `mimeType` still defaults to `text/plain`/`application/octet-stream` as before.
-* Add `annotations` support to `ImageContent` (constructor, `fromArray()`, `fromFile()`, `fromString()`, `jsonSerialize()`), matching `TextContent` and `AudioContent`.
-* Add client-side `roots/list` handler (`ListRootsRequestHandler` + `RootsCallbackInterface`) and `Client::sendRootsListChanged()`, plus server-side `ClientGateway::listRoots()` / `supportsRoots()` and `ListRootsResult::fromArray()`.
-* Add `ClientGateway::supportsSampling()`, so a tool can check the client's advertised capabilities before issuing a `sampling/createMessage` request instead of asking and catching the refusal. Matches the existing `supportsRoots()` and `supportsElicitation()`.
-* [BC Break] Gate `structuredContent` on the negotiated protocol revision: `ToolReference::extractStructuredContent()` takes an optional `ProtocolVersion` and, for revisions predating SEP-2106 (`2025-11-25` and earlier, where `structuredContent` must be a JSON object), returns `null` for a tool result that is a PHP list or an object serializing to a JSON array. From `2026-07-28` on both are emitted as-is. Objects serializing to a scalar and arrays holding `Content` instances are never emitted, in any revision. `CallToolHandler` resolves the revision from the request's `_meta` (modern era) or the session (handshake era) and falls back to the strictest rule; it logs a warning when a tool declares an `outputSchema` but returns a value that cannot be sent, and when a self-built `CallToolResult` carries a `structuredContent` the revision does not allow (that one is passed through unchanged). Tools returning a list against an older client keep their JSON-encoded value in `content`; they just no longer advertise an invalid `structuredContent`.
-* Add `Mcp\Schema\Content\ResourceLink` for the spec's `resource_link` content block (protocol revision 2025-06-18+), letting tool results and prompt messages reference a resource by URI/name without embedding its contents. Accepted anywhere `resource` (`EmbeddedResource`) content is (de)serialized: `CallToolResult::fromArray()`, `PromptMessage::fromArray()`, and `PromptResultFormatter`.
-* Negotiate the protocol revision during the `initialize` handshake: the server echoes a revision it supports and counter-offers `ProtocolVersion::latestHandshake()` otherwise (`Builder::setProtocolVersion()` pins it to exactly one), and the client fails the handshake on a counter-offer it cannot speak rather than continuing on an unagreed revision. Adds `Client::getProtocolVersion()`, the `2026-07-28` revision, and the era helpers on `ProtocolVersion` — revisions from `2026-07-28` on have no `initialize`, so they are excluded from negotiation and from `ProtocolVersionMiddleware`'s default supported set.
-* Add sampling with tools support: sampling requests now accept tools and tool-choice preferences, messages support tool-use/tool-result content blocks and multiple content blocks, and clients can advertise the `sampling.context` and `sampling.tools` capabilities. Adds `ClientGateway::supportsSamplingTools()` / `supportsSamplingContext()` to check the sub-capabilities before sending, and `CreateSamplingMessageRequest::validateToolFlow()`, which asserts the spec's tool-flow rules across the whole message list — the client handler rejects a violating request with `-32602` instead of leaving it unanswered, and the gateway refuses to send one.
-* [BC Break] `SamplingMessage::$content` and `CreateSamplingMessageResult::$content` may now hold a list of content blocks instead of a single one, so code reading them directly must handle both. Use the new `getContentBlocks()` on either class to always get a list.
-* [BC Break] `CreateSamplingMessageResult` now rejects any role other than `assistant`, and rejects empty content, as the specification requires.
-
-0.7.0
------
-
-* Add client-side elicitation support: `ElicitationCallbackInterface`, `ElicitationRequestHandler`, and `ElicitationException` let clients respond to server elicitation requests.
-* Defer element loading to the first registry read: loaders now run at request time (first `has*`/`get*` call) instead of eagerly at `Builder::build()`, fixing empty registries under persistent runtimes (e.g. FrankenPHP worker mode) where a loader's data source is not ready at build time. Adds `Builder::setLazyLoading()` (default on), a public `Registry::load()`, and an optional `LoaderInterface` constructor argument on `Registry`.
-* [BC Break] Element loading is lazy by default: loader failures now surface on the first request rather than at `Builder::build()`, and `initialize` advertises capabilities from the configured sources rather than the loaded registry. Call `Builder::setLazyLoading(false)` to restore eager build-time loading.
-* Allow `[$instance, 'methodName']` as an element handler in `Builder::addTool()`, `addResource()`, `addResourceTemplate()`, and `addPrompt()`. Unblocks handlers with constructor dependencies that the container-less `new $className()` fallback cannot build.
-* Always emit an `items` schema for array tool parameters: untyped arrays get `items: {}` and nullable typed arrays (e.g. `string[]|null`) keep their element type. Fixes strict clients rejecting tools with "array type must have items" (#151).
-* Harden JSON-RPC input parsing: single-message vs batch is now decided from the decoded JSON type (object → single, list array → batch) instead of the raw first byte. Scalars, empty payloads, and non-object batch elements are surfaced as `InvalidInputMessageException` entries instead of triggering warnings or a `TypeError`.
-* Add `maxBatchSize` (default `100`) to `MessageFactory` — oversized JSON-RPC batches are rejected before any message is constructed, guarding against amplification.
-* Add `maxBodyBytes` (default 4 MiB) to `StreamableHttpTransport` — POST bodies exceeding the cap are rejected with `413`. Unknown-size/chunked bodies are read incrementally and stopped at the cap so they cannot exhaust memory.
-* Reject malformed `Mcp-Session-Id` headers with a `400` response: a repeated header or a value that is not a valid UUID is now rejected up front instead of surfacing as an uncaught `Uuid::fromString()` error.
-* Extract RFC 9728 metadata serving into `ProtectedResourceMetadataHandler`, a transport-neutral PSR-15 `RequestHandlerInterface` that can be mounted directly as a Symfony/Laravel controller; `ProtectedResourceMetadataMiddleware` now delegates to it (no BC break).
-
-0.6.0
------
-
-* Add `Builder::add(Tool|ResourceDefinition|ResourceTemplate|Prompt $definition, ElementHandlerInterface $handler)` for explicit registration of elements whose schema is only known at runtime.
-* Add handler interfaces `ToolHandlerInterface`, `ResourceHandlerInterface`, `ResourceTemplateHandlerInterface`, `PromptHandlerInterface`, and the `ElementHandlerInterface` marker.
-* [BC Break] Renamed `Mcp\Schema\Resource` to `Mcp\Schema\ResourceDefinition`. No alias.
-* [BC Break] Renamed `Mcp\Capability\Registry\Loader\ArrayLoader` to `Mcp\Capability\Registry\Loader\ReflectedElementLoader`.
-* [BC Break] Bump default protocol version to `2025-11-25`
-* Add support for MCP Apps extension in schema and server
-* Add `extensions` to `ServerCapabilities` and `ClientCapabilities` and `Builder::enableExtension()`
-* Allow overriding the default name pattern for Discovery
-* Add configurable session garbage collection (`gcProbability`/`gcDivisor`)
-* Add optional `title` field to `ResourceDefinition` and `ResourceTemplate` for MCP spec compliance
-* Add `ChainLoader` to compose multiple `LoaderInterface` implementations via explicit ordering.
-* Add `RegistryInterface::unregisterTool()`, `unregisterResource()`, `unregisterResourceTemplate()`, `unregisterPrompt()` — idempotent removals.
-* Add `RegistryInterface::hasTool()`, `hasResource()`, `hasResourceTemplate()`, `hasPrompt()` — by-name existence checks.
-* `DiscoveryLoader` now refreshes only its own previously written entries; manual registrations (via `Builder::addTool()` etc. or runtime `$registry->registerTool()` calls) survive rediscovery, and a same-name manual registration takes precedence over discovery on collision.
-* [BC Break] Removed `ElementReference::$isManual` public property and the `bool $isManual` parameter from all `*Reference` constructors. Origin tracking is no longer carried on the element; manual-over-discovered precedence is encoded by loader execution order.
-* [BC Break] `RegistryInterface::registerTool()`, `registerResource()`, `registerResourceTemplate()`, `registerPrompt()` lost their trailing `bool $isManual = false` parameter. Callers using positional arguments must drop the flag.
-* [BC Break] Removed `RegistryInterface::clear()`, `getDiscoveryState()`, `setDiscoveryState()`. Rediscovery now goes through `DiscoveryLoader::load()` directly.
-* `Registry::register*()` semantics changed to plain last-write-wins (overwrites silently) and the methods now return the stored `*Reference`. The previous "discovered registration is ignored when a manual one already exists" precedence rule still applies, but is now enforced by `DiscoveryLoader` via reference-identity tracking — and still emits a debug log when a discovery is skipped due to a conflicting registration.
-* Add optional `title` parameter to `Builder::addResource()` and `Builder::addResourceTemplate()` for MCP spec compliance
-* [BC Break] `Builder::addResource()` signature changed — `$title` parameter added between `$name` and `$description`. Callers using positional arguments must switch to named arguments.
-* [BC Break] `Builder::addResourceTemplate()` signature changed — `$title` parameter added between `$name` and `$description`. Callers using positional arguments must switch to named arguments.
-* Add `CorsMiddleware`, `DnsRebindingProtectionMiddleware`, and `ProtocolVersionMiddleware` for `StreamableHttpTransport`, composed automatically as the default stack via `StreamableHttpTransport::defaultMiddleware()`
-* [BC BREAK] `StreamableHttpTransport` constructor: `$corsHeaders` parameter removed; CORS is now configured via `CorsMiddleware`. The `$middleware` parameter is nullable — `null` (or omitted) installs the default stack; `[]` disables all defaults. Default `Access-Control-Allow-Origin` is no longer set (was `*`).
-* [BC Break] `ResourceDefinition::__construct()` signature changed — `$title` parameter added between `$name` and `$description`. Callers using positional arguments must switch to named arguments.
-* [BC Break] `ResourceTemplate::__construct()` signature changed — `$title` parameter added between `$name` and `$description`. Callers using positional arguments must switch to named arguments.
-* [BC Break] `McpResource` and `McpResourceTemplate` attribute signatures changed — `$title` parameter added between `$name` and `$description`. Callers using positional arguments must switch to named arguments.
-
-0.5.0
------
-
-* Add built-in authentication middleware for HTTP transport using OAuth
-* Add client component for building MCP clients
-* Add `Builder::setReferenceHandler()` to allow custom `ReferenceHandlerInterface` implementations (e.g. authorization decorators)
-* Add elicitation enum schema types per SEP-1330: `TitledEnumSchemaDefinition`, `MultiSelectEnumSchemaDefinition`, `TitledMultiSelectEnumSchemaDefinition`
-* [BC break] Make Symfony Finder component optional. Users would need to install `symfony/finder` now themselves
-* Add `LenientOidcDiscoveryMetadataPolicy` for identity providers that omit `code_challenge_methods_supported` (e.g. FusionAuth, Microsoft Entra ID)
-* Add OAuth 2.0 Dynamic Client Registration middleware (RFC 7591)
-* Add optional `title` field to `Prompt` and `McpPrompt` for MCP spec compliance
-* [BC Break] `Builder::addPrompt()` signature changed — `$title` parameter added between `$name` and `$description`. Callers using positional arguments for `$description` must switch to named arguments.
-* Add optional `title` field to `Tool` and `McpTool` for MCP spec compliance
-* [BC Break] `Tool::__construct()` signature changed — `$title` parameter added between `$name` and `$inputSchema`. Callers using positional arguments must switch to named arguments or pass `null` for `$title`.
-* [BC Break] `McpTool` attribute signature changed — `$title` parameter added between `$name` and `$description`. Callers using positional arguments for `$description` must switch to named arguments.
-* [BC Break] `Builder::addTool()` signature changed — `$title` parameter added between `$name` and `$description`. Callers using positional arguments for `$description` must switch to named arguments.
-
-0.4.0
------
-
-* Rename `Mcp\Server\Session\Psr16StoreSession` to `Mcp\Server\Session\Psr16SessionStore`
-* Add missing handlers for resource subscribe/unsubscribe and persist subscriptions via session
-* Introduce `SessionManager` to encapsulate session handling (replaces `SessionFactory`) and move garbage collection logic from `Protocol`.
-
-0.3.0
------
-
-* Add output schema support to MCP tools
-* Add validation of the input parameters given to a Tool.
-* Rename `Mcp\Capability\Registry\ResourceReference::$schema` to `Mcp\Capability\Registry\ResourceReference::$resource`.
-* Introduce `SchemaGeneratorInterface` and `DiscovererInterface` to allow custom schema generation and discovery implementations.
-* Remove `DocBlockParser::getSummary()` method, use `DocBlockParser::getDescription()` instead.
-
-0.2.2
------
-
-* Throw exception when trying to inject parameter with the unsupported names `$_session` or `$_request`.
-* `Throwable` objects are passed to log context instead of the exception message.
-
-0.2.1
------
-
-* Add `RunnerControl` for `StdioTransport` to allow break out from continuously listening for new input.
-* Open range of supported Symfony versions to include v5.4
-
-0.2.0
------
-
-* Make `Protocol` stateless by decouple if from `TransportInterface`. Removed `Protocol::getTransport()`.
-* Change signature of `Builder::addLoaders(...$loaders)` to `Builder::addLoaders(iterable $loaders)`.
-* Removed `ClientAwareInterface` in favor of injecting a `RequestContext` with argument injection.
-* The `ClientGateway` cannot be injected with argument injection anymore. Use `RequestContext` instead.
-* Removed `ClientAwareTrait`
-* Removed `Protocol::getTransport()`
-* Added parameter for `TransportInterface` to `Protocol::processInput()`
-
-0.1.0
------
-
-* First tagged release of package
-* Support for implementing MCP server
diff --git a/LICENSE b/LICENSE
deleted file mode 100644
index 4a939857..00000000
--- a/LICENSE
+++ /dev/null
@@ -1,216 +0,0 @@
-The MCP project is undergoing a licensing transition from the MIT License to the Apache License, Version 2.0 ("Apache-2.0"). All new code and specification contributions to the project are licensed under Apache-2.0. Documentation contributions (excluding specifications) are licensed under CC-BY-4.0.
-
-Contributions for which relicensing consent has been obtained are licensed under Apache-2.0. Contributions made by authors who originally licensed their work under the MIT License and who have not yet granted explicit permission to relicense remain licensed under the MIT License.
-
-No rights beyond those granted by the applicable original license are conveyed for such contributions.
-
----
-
- Apache License
- Version 2.0, January 2004
- http://www.apache.org/licenses/
-
- TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
-
- 1. Definitions.
-
- "License" shall mean the terms and conditions for use, reproduction,
- and distribution as defined by Sections 1 through 9 of this document.
-
- "Licensor" shall mean the copyright owner or entity authorized by
- the copyright owner that is granting the License.
-
- "Legal Entity" shall mean the union of the acting entity and all
- other entities that control, are controlled by, or are under common
- control with that entity. For the purposes of this definition,
- "control" means (i) the power, direct or indirect, to cause the
- direction or management of such entity, whether by contract or
- otherwise, or (ii) ownership of fifty percent (50%) or more of the
- outstanding shares, or (iii) beneficial ownership of such entity.
-
- "You" (or "Your") shall mean an individual or Legal Entity
- exercising permissions granted by this License.
-
- "Source" form shall mean the preferred form for making modifications,
- including but not limited to software source code, documentation
- source, and configuration files.
-
- "Object" form shall mean any form resulting from mechanical
- transformation or translation of a Source form, including but
- not limited to compiled object code, generated documentation,
- and conversions to other media types.
-
- "Work" shall mean the work of authorship, whether in Source or
- Object form, made available under the License, as indicated by a
- copyright notice that is included in or attached to the work
- (an example is provided in the Appendix below).
-
- "Derivative Works" shall mean any work, whether in Source or Object
- form, that is based on (or derived from) the Work and for which the
- editorial revisions, annotations, elaborations, or other modifications
- represent, as a whole, an original work of authorship. For the purposes
- of this License, Derivative Works shall not include works that remain
- separable from, or merely link (or bind by name) to the interfaces of,
- the Work and Derivative Works thereof.
-
- "Contribution" shall mean any work of authorship, including
- the original version of the Work and any modifications or additions
- to that Work or Derivative Works thereof, that is intentionally
- submitted to the Licensor for inclusion in the Work by the copyright
- owner or by an individual or Legal Entity authorized to submit on behalf
- of the copyright owner. For the purposes of this definition, "submitted"
- means any form of electronic, verbal, or written communication sent
- to the Licensor or its representatives, including but not limited to
- communication on electronic mailing lists, source code control systems,
- and issue tracking systems that are managed by, or on behalf of, the
- Licensor for the purpose of discussing and improving the Work, but
- excluding communication that is conspicuously marked or otherwise
- designated in writing by the copyright owner as "Not a Contribution."
-
- "Contributor" shall mean Licensor and any individual or Legal Entity
- on behalf of whom a Contribution has been received by Licensor and
- subsequently incorporated within the Work.
-
- 2. Grant of Copyright License. Subject to the terms and conditions of
- this License, each Contributor hereby grants to You a perpetual,
- worldwide, non-exclusive, no-charge, royalty-free, irrevocable
- copyright license to reproduce, prepare Derivative Works of,
- publicly display, publicly perform, sublicense, and distribute the
- Work and such Derivative Works in Source or Object form.
-
- 3. Grant of Patent License. Subject to the terms and conditions of
- this License, each Contributor hereby grants to You a perpetual,
- worldwide, non-exclusive, no-charge, royalty-free, irrevocable
- (except as stated in this section) patent license to make, have made,
- use, offer to sell, sell, import, and otherwise transfer the Work,
- where such license applies only to those patent claims licensable
- by such Contributor that are necessarily infringed by their
- Contribution(s) alone or by combination of their Contribution(s)
- with the Work to which such Contribution(s) was submitted. If You
- institute patent litigation against any entity (including a
- cross-claim or counterclaim in a lawsuit) alleging that the Work
- or a Contribution incorporated within the Work constitutes direct
- or contributory patent infringement, then any patent licenses
- granted to You under this License for that Work shall terminate
- as of the date such litigation is filed.
-
- 4. Redistribution. You may reproduce and distribute copies of the
- Work or Derivative Works thereof in any medium, with or without
- modifications, and in Source or Object form, provided that You
- meet the following conditions:
-
- (a) You must give any other recipients of the Work or
- Derivative Works a copy of this License; and
-
- (b) You must cause any modified files to carry prominent notices
- stating that You changed the files; and
-
- (c) You must retain, in the Source form of any Derivative Works
- that You distribute, all copyright, patent, trademark, and
- attribution notices from the Source form of the Work,
- excluding those notices that do not pertain to any part of
- the Derivative Works; and
-
- (d) If the Work includes a "NOTICE" text file as part of its
- distribution, then any Derivative Works that You distribute must
- include a readable copy of the attribution notices contained
- within such NOTICE file, excluding those notices that do not
- pertain to any part of the Derivative Works, in at least one
- of the following places: within a NOTICE text file distributed
- as part of the Derivative Works; within the Source form or
- documentation, if provided along with the Derivative Works; or,
- within a display generated by the Derivative Works, if and
- wherever such third-party notices normally appear. The contents
- of the NOTICE file are for informational purposes only and
- do not modify the License. You may add Your own attribution
- notices within Derivative Works that You distribute, alongside
- or as an addendum to the NOTICE text from the Work, provided
- that such additional attribution notices cannot be construed
- as modifying the License.
-
- You may add Your own copyright statement to Your modifications and
- may provide additional or different license terms and conditions
- for use, reproduction, or distribution of Your modifications, or
- for any such Derivative Works as a whole, provided Your use,
- reproduction, and distribution of the Work otherwise complies with
- the conditions stated in this License.
-
- 5. Submission of Contributions. Unless You explicitly state otherwise,
- any Contribution intentionally submitted for inclusion in the Work
- by You to the Licensor shall be under the terms and conditions of
- this License, without any additional terms or conditions.
- Notwithstanding the above, nothing herein shall supersede or modify
- the terms of any separate license agreement you may have executed
- with Licensor regarding such Contributions.
-
- 6. Trademarks. This License does not grant permission to use the trade
- names, trademarks, service marks, or product names of the Licensor,
- except as required for reasonable and customary use in describing the
- origin of the Work and reproducing the content of the NOTICE file.
-
- 7. Disclaimer of Warranty. Unless required by applicable law or
- agreed to in writing, Licensor provides the Work (and each
- Contributor provides its Contributions) on an "AS IS" BASIS,
- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
- implied, including, without limitation, any warranties or conditions
- of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
- PARTICULAR PURPOSE. You are solely responsible for determining the
- appropriateness of using or redistributing the Work and assume any
- risks associated with Your exercise of permissions under this License.
-
- 8. Limitation of Liability. In no event and under no legal theory,
- whether in tort (including negligence), contract, or otherwise,
- unless required by applicable law (such as deliberate and grossly
- negligent acts) or agreed to in writing, shall any Contributor be
- liable to You for damages, including any direct, indirect, special,
- incidental, or consequential damages of any character arising as a
- result of this License or out of the use or inability to use the
- Work (including but not limited to damages for loss of goodwill,
- work stoppage, computer failure or malfunction, or any and all
- other commercial damages or losses), even if such Contributor
- has been advised of the possibility of such damages.
-
- 9. Accepting Warranty or Additional Liability. While redistributing
- the Work or Derivative Works thereof, You may choose to offer,
- and charge a fee for, acceptance of support, warranty, indemnity,
- or other liability obligations and/or rights consistent with this
- License. However, in accepting such obligations, You may act only
- on Your own behalf and on Your sole responsibility, not on behalf
- of any other Contributor, and only if You agree to indemnify,
- defend, and hold each Contributor harmless for any liability
- incurred by, or claims asserted against, such Contributor by reason
- of your accepting any such warranty or additional liability.
-
- END OF TERMS AND CONDITIONS
-
----
-
-MIT License
-
-Copyright (c) 2024-2025 Model Context Protocol a Series of LF Projects, LLC.
-
-Permission is hereby granted, free of charge, to any person obtaining a copy
-of this software and associated documentation files (the "Software"), to deal
-in the Software without restriction, including without limitation the rights
-to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-copies of the Software, and to permit persons to whom the Software is
-furnished to do so, subject to the following conditions:
-
-The above copyright notice and this permission notice shall be included in all
-copies or substantial portions of the Software.
-
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
-SOFTWARE.
-
----
-
-Creative Commons Attribution 4.0 International (CC-BY-4.0)
-
-Documentation in this project (excluding specifications) is licensed under
-CC-BY-4.0. See https://creativecommons.org/licenses/by/4.0/legalcode for
-the full license text.
diff --git a/Makefile b/Makefile
deleted file mode 100644
index c886bc5e..00000000
--- a/Makefile
+++ /dev/null
@@ -1,55 +0,0 @@
-.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 docs
-
-deps-stable:
- composer update --prefer-stable
-
-deps-low:
- composer update --prefer-lowest
-
-cs:
- vendor/bin/php-cs-fixer fix --diff --verbose
-
-phpstan:
- vendor/bin/phpstan --memory-limit=-1
-
-tests:
- vendor/bin/phpunit
-
-unit-tests:
- vendor/bin/phpunit --testsuite=unit
-
-integration-tests:
- vendor/bin/phpunit --testsuite=integration
-
-inspector-tests:
- vendor/bin/phpunit --testsuite=inspector
-
-conformance-tests: conformance-server conformance-client
-
-conformance-server:
- docker compose -f tests/Conformance/Fixtures/docker-compose.yml up -d
- @echo "Waiting for server to start..."
- @sleep 5
- rm -rf tests/Conformance/results
- cd tests/Conformance && npx @modelcontextprotocol/conformance server --url http://localhost:8000/ --output-dir results || true
- php tests/Conformance/score.php server
- docker compose -f tests/Conformance/Fixtures/docker-compose.yml down
-
-conformance-client:
- rm -rf tests/Conformance/results
- cd tests/Conformance && npx @modelcontextprotocol/conformance client --command "php $(CURDIR)/tests/Conformance/client.php" --suite all --expected-failures conformance-baseline.yml --output-dir results || true
- php tests/Conformance/score.php client
-
-coverage:
- XDEBUG_MODE=coverage vendor/bin/phpunit --testsuite=unit --coverage-html=coverage
-
-ci: ci-stable
-
-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)
diff --git a/README.md b/README.md
deleted file mode 100644
index 4e1a868d..00000000
--- a/README.md
+++ /dev/null
@@ -1,334 +0,0 @@
-# MCP PHP SDK
-
-
-
-[](https://packagist.org/packages/mcp/sdk)
-[](https://github.com/modelcontextprotocol/php-sdk/actions/workflows/pipeline.yaml)
-[](https://packagist.org/packages/mcp/sdk)
-[](LICENSE)
-[](https://github.com/modelcontextprotocol/php-sdk/actions/workflows/conformance-weekly.yaml)
-[](https://github.com/modelcontextprotocol/php-sdk/actions/workflows/conformance-weekly.yaml)
-
-
-
-The official PHP SDK for Model Context Protocol (MCP). It provides a framework-agnostic API for implementing MCP servers
-and clients in PHP.
-
-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
-[Backward Compatibility Promise](https://symfony.com/doc/current/contributing/code/bc.html).
-
-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.
-
-### Quick Start
-
-```php
-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
-{
- #[McpTool]
- public function add(int $a, int $b): int
- {
- return $a + $b;
- }
-
- #[McpResource(uri: 'config://calculator/settings')]
- public function getSettings(): 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);
-```
-
-### Session Management
-
-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/server-builder.md)
-
-## Client SDK
-
-Connect to MCP servers from your PHP applications to access their tools, resources, and prompts.
-
-### Quick Start
-
-```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);
-
-// 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.md)
-
-## Documentation
-
-### 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
-
-### 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
-- **[Model Context Protocol Specification](https://spec.modelcontextprotocol.io)** — Protocol specification
-- **[Officially Supported Servers](https://github.com/modelcontextprotocol/servers)** — Reference server implementations
-
-## PHP Libraries Using the MCP SDK
-
-- [api-platform/mcp](https://github.com/api-platform/mcp) — MCP integration for API Platform
-- [bnomei/kirby-mcp](https://github.com/bnomei/kirby-mcp) — MCP server for the Kirby CMS
-- [drupal/mcp_server](https://www.drupal.org/project/mcp_server) — MCP server for Drupal exposing configuration and entities as MCP elements
-- [josbeir/cakephp-synapse](https://github.com/josbeir/cakephp-synapse) — CakePHP plugin exposing application functionality over MCP
-- [nette/mcp-inspector](https://github.com/nette/mcp-inspector) — MCP server for introspecting Nette applications
-- [symfony/ai-mate](https://github.com/symfony/ai-mate) — AI development assistant MCP server for Symfony projects
-- [symfony/mcp-bundle](https://github.com/symfony/mcp-bundle) — Symfony integration bundle
-
-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).
-
-## Credits
-
-The starting point for this SDK was the [PHP-MCP](https://github.com/php-mcp/server) project, initiated by [Kyrian Obikwelu](https://github.com/CodeWithKyrian), and the [Symfony AI initiative](https://github.com/symfony/ai). We are grateful for the work done by both projects and their contributors, which created a solid foundation for this SDK.
-
-## License
-
-This project is licensed under the Apache License, Version 2.0 for new contributions, with existing code under the MIT License — see the [LICENSE](LICENSE) file for details.
diff --git a/ROADMAP.md b/ROADMAP.md
deleted file mode 100644
index 5b67e14f..00000000
--- a/ROADMAP.md
+++ /dev/null
@@ -1,15 +0,0 @@
-# Roadmap
-
-This roadmap is a living document that outlines the planned features and improvements for our project.
-
-## Goals for the First Major Release
-
-- **Server**
-- [x] Implement full support for elicitations
-- [ ] Implement OAuth2 authentication for server
-- **Client**
-- [x] Implement client-side support
-- [x] Implement client examples and documentation
-- **Schema**
-- [ ] Implement schema generation based on TS or JSON Schema
-
diff --git a/SECURITY.md b/SECURITY.md
deleted file mode 100644
index 50292420..00000000
--- a/SECURITY.md
+++ /dev/null
@@ -1,21 +0,0 @@
-# Security Policy
-
-Thank you for helping keep the Model Context Protocol and its ecosystem secure.
-
-## Reporting Security Issues
-
-If you discover a security vulnerability in this repository, please report it through
-the [GitHub Security Advisory process](https://docs.github.com/en/code-security/security-advisories/guidance-on-reporting-and-writing-information-about-vulnerabilities/privately-reporting-a-security-vulnerability)
-for this repository.
-
-Please **do not** report security vulnerabilities through public GitHub issues, discussions,
-or pull requests.
-
-## What to Include
-
-To help us triage and respond quickly, please include:
-
-- A description of the vulnerability
-- Steps to reproduce the issue
-- The potential impact
-- Any suggested fixes (optional)
diff --git a/adr/0001-oauth-authorization-server-out-of-scope.md b/adr/0001-oauth-authorization-server-out-of-scope.md
deleted file mode 100644
index fa5b091f..00000000
--- a/adr/0001-oauth-authorization-server-out-of-scope.md
+++ /dev/null
@@ -1,97 +0,0 @@
-# 0001 — The MCP server is an OAuth Resource Server, not an Authorization Server
-
-- Status: Accepted
-- Date: 2026-06-15
-
-## Context
-
-OAuth 2.1 defines three distinct roles:
-
-| Role | What it does | Status in this SDK | Scope |
-|------|--------------|--------------------|-------|
-| Resource Server | Validates incoming bearer tokens, serves Protected Resource Metadata (RFC 9728), emits `WWW-Authenticate` | Shipped (`AuthorizationMiddleware`, `JwtTokenValidator`, `ProtectedResourceMetadata`) | **IN scope** |
-| Delegation / proxy to an upstream AS | Forwards `/authorize` and `/token` to your existing IdP | Shipped (`OAuthProxyMiddleware`) | **IN scope — delegation ONLY** |
-| Authorization Server / Identity Provider (IdP) | Mints its own tokens, registers clients, runs login and consent | Absent | **OUT of scope** |
-
-The SDK repeatedly receives pull requests that move it toward becoming a full OAuth 2.1
-**authorization server** — asking the MCP server to mint its own access and refresh tokens,
-register clients, and run login and consent flows, that is, to become an Identity Provider.
-The most explicit example is [#373](https://github.com/modelcontextprotocol/php-sdk/pull/373)
-("[Server] Add native OAuth 2.1 authorization server", ~3,400 lines across 50+ files), but the
-direction has crept in incrementally rather than in one PR.
-
-A contributing factor is that the SDK already exposes the *authorization-server endpoints in
-proxy form*, and that surface has grown:
-[#221](https://github.com/modelcontextprotocol/php-sdk/pull/221) added the OAuth resource-server
-middleware, and [#269](https://github.com/modelcontextprotocol/php-sdk/pull/269) added Dynamic
-Client Registration (RFC 7591). Today `OAuthProxyMiddleware` answers `/authorize`, `/token`, and
-`/.well-known/oauth-authorization-server`, and DCR endpoints exist. The shape of that surface
-invites contributors to "finish the job" by backing those endpoints with a real token issuer.
-It is not scaffolding to be completed — it is a delegating proxy, and that is the whole of its
-intent.
-
-## Decision
-
-**The MCP server is an OAuth 2.1 Resource Server that MAY delegate to an upstream
-authorization server. It will NOT issue tokens or act as an Identity Provider.**
-
-Concretely:
-
-- The SDK validates bearer tokens, serves RFC 9728 Protected Resource Metadata, and emits
- `WWW-Authenticate` challenges. This is the Resource Server role and is fully supported.
-- The SDK MAY delegate `/authorize` and `/token` to an upstream authorization server via
- `OAuthProxyMiddleware`. This is delegation only: the middleware redirects the browser to
- the upstream `/authorize` endpoint and proxies `/token` requests to the upstream token
- endpoint. It never mints, signs, stores, or rotates tokens of its own.
-- The SDK will NOT implement an authorization server: no token issuance, no token signing or
- key management, no login UI, no consent UI, no authorization-code or refresh-token storage,
- no first-party Dynamic Client Registration acting as an issuer.
-
-Pull requests that add authorization-server / IdP behavior are declined by reference to this
-ADR.
-
-## Rationale
-
-- **Security liability.** Issuing tokens means owning signing-key generation, storage, and
- rotation; authorization-code and refresh-token persistence; refresh-token rotation and
- replay detection; and consent. A defect in any of these is a credential-issuance
- vulnerability affecting every consumer of the SDK. This is precisely the surface an MCP SDK
- should not own.
-- **RFC footprint.** A correct authorization server must implement and keep current with
- RFC 6749 (OAuth 2.0), RFC 7591 (Dynamic Client Registration), RFC 8414 (Authorization
- Server Metadata), PKCE (RFC 7636), and refresh-token rotation guidance, among others. That
- is an open-ended maintenance and conformance burden far outside the SDK's purpose.
-- **Mature implementations already exist.** Token issuance is a solved problem.
- `league/oauth2-server` provides it as a PHP library, and every production IdP — Keycloak,
- Auth0, Microsoft Entra ID, Okta — provides it as a service. Re-implementing it inside an
- MCP SDK adds risk without adding value.
-
-## Boundary statement
-
-`OAuthProxyMiddleware` **delegates** to an upstream IdP. It is **not** authorization-server
-scaffolding to be completed. Backing its `/authorize` and `/token` endpoints with a
-first-party token issuer is out of scope and will be declined.
-
-## Consequences
-
-- The supported authorization architecture is: an external authorization server (your IdP or
- `league/oauth2-server` running in your own application) issues tokens; the MCP server
- validates them as a Resource Server and optionally proxies the OAuth flow to that upstream.
-- Contributors get a single, citable ruling for why authorization-server PRs are declined,
- reducing repeated large-PR churn.
-- Resource Server and proxy/delegation features remain welcome and supported.
-
-## Alternatives / what to do instead
-
-If you need an authorization server (token issuance, client registration, login, consent), do
-**not** add it to this SDK. Instead:
-
-- **Front the MCP server with an existing IdP** — Keycloak, Auth0, Microsoft Entra ID, or
- Okta. Point `JwtTokenValidator` and `ProtectedResourceMetadata` at that issuer, and
- optionally use `OAuthProxyMiddleware` to delegate `/authorize` and `/token` to it.
-- **Run `league/oauth2-server` in your own application**, behind the SDK's existing proxy and
- 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
-and delegation setup.
diff --git a/adr/README.md b/adr/README.md
deleted file mode 100644
index d8ab0dea..00000000
--- a/adr/README.md
+++ /dev/null
@@ -1,10 +0,0 @@
-# Architecture Decision Records
-
-This directory holds Architecture Decision Records (ADRs): short documents that capture a
-significant architectural decision, the context that led to it, and its consequences. They
-exist so a decision can be cited once and referenced thereafter — for example, when declining
-a pull request that conflicts with an accepted decision.
-
-## Records
-
-- [0001 — The MCP server is an OAuth Resource Server, not an Authorization Server](0001-oauth-authorization-server-out-of-scope.md)
diff --git a/client-conformance.json b/client-conformance.json
new file mode 100644
index 00000000..cad18e92
--- /dev/null
+++ b/client-conformance.json
@@ -0,0 +1,6 @@
+{
+ "schemaVersion": 1,
+ "label": "client conformance",
+ "message": "3/58 (5%)",
+ "color": "orange"
+}
diff --git a/composer.json b/composer.json
deleted file mode 100644
index b883603b..00000000
--- a/composer.json
+++ /dev/null
@@ -1,96 +0,0 @@
-{
- "name": "mcp/sdk",
- "description": "Model Context Protocol SDK for Client and Server applications in PHP",
- "license": "Apache-2.0",
- "type": "library",
- "authors": [
- {
- "name": "Christopher Hertel",
- "email": "mail@christopher-hertel.de"
- },
- {
- "name": "Kyrian Obikwelu",
- "email": "koshnawaza@gmail.com"
- },
- {
- "name": "Tobias Nyholm",
- "email": "tobias.nyholm@gmail.com"
- }
- ],
- "require": {
- "php": "^8.1",
- "ext-fileinfo": "*",
- "opis/json-schema": "^2.4",
- "php-http/discovery": "^1.20",
- "phpdocumentor/reflection-docblock": "^5.6 || ^6.0",
- "psr/clock": "^1.0",
- "psr/container": "^1.0 || ^2.0",
- "psr/event-dispatcher": "^1.0",
- "psr/http-client": "^1.0",
- "psr/http-factory": "^1.1",
- "psr/http-message": "^1.1 || ^2.0",
- "psr/http-server-handler": "^1.0",
- "psr/http-server-middleware": "^1.0",
- "psr/log": "^1.0 || ^2.0 || ^3.0",
- "symfony/uid": "^5.4 || ^6.4 || ^7.3 || ^8.0"
- },
- "suggest": {
- "symfony/finder": "Required for file-based discovery."
- },
- "require-dev": {
- "ext-openssl": "*",
- "composer/semver": "^3.0",
- "firebase/php-jwt": "^6.10 || ^7.0",
- "laminas/laminas-httphandlerrunner": "^2.12",
- "nyholm/psr7": "^1.8",
- "nyholm/psr7-server": "^1.1",
- "phar-io/composer-distributor": "^1.0.2",
- "php-cs-fixer/shim": "^3.91",
- "phpdocumentor/shim": "^3",
- "phpstan/phpstan": "^2.1",
- "phpunit/phpunit": "^10.5",
- "psr/simple-cache": "^2.0 || ^3.0",
- "symfony/cache": "^5.4 || ^6.4 || ^7.3 || ^8.0",
- "symfony/console": "^5.4 || ^6.4 || ^7.3 || ^8.0",
- "symfony/finder": "^5.4 || ^6.4 || ^7.3 || ^8.0",
- "symfony/http-client": "^5.4 || ^6.4 || ^7.3 || ^8.0",
- "symfony/process": "^5.4 || ^6.4 || ^7.3 || ^8.0"
- },
- "autoload": {
- "psr-4": {
- "Mcp\\": "src/"
- }
- },
- "autoload-dev": {
- "psr-4": {
- "Mcp\\Example\\Server\\CachedDiscovery\\": "examples/server/cached-discovery/",
- "Mcp\\Example\\Server\\ClientCommunication\\": "examples/server/client-communication/",
- "Mcp\\Example\\Server\\ClientLogging\\": "examples/server/client-logging/",
- "Mcp\\Example\\Server\\CombinedRegistration\\": "examples/server/combined-registration/",
- "Mcp\\Example\\Server\\Elicitation\\": "examples/server/elicitation/",
- "Mcp\\Example\\Server\\ComplexToolSchema\\": "examples/server/complex-tool-schema/",
- "Mcp\\Example\\Server\\Conformance\\": "examples/server/conformance/",
- "Mcp\\Example\\Server\\CustomDependencies\\": "examples/server/custom-dependencies/",
- "Mcp\\Example\\Server\\CustomMethodHandlers\\": "examples/server/custom-method-handlers/",
- "Mcp\\Example\\Server\\DiscoveryCalculator\\": "examples/server/discovery-calculator/",
- "Mcp\\Example\\Server\\DiscoveryUserProfile\\": "examples/server/discovery-userprofile/",
- "Mcp\\Example\\Server\\EnvVariables\\": "examples/server/env-variables/",
- "Mcp\\Example\\Server\\ExplicitRegistration\\": "examples/server/explicit-registration/",
- "Mcp\\Example\\Server\\McpApps\\": "examples/server/mcp-apps/",
- "Mcp\\Example\\Server\\OAuthKeycloak\\": "examples/server/oauth-keycloak/",
- "Mcp\\Example\\Server\\OAuthMicrosoft\\": "examples/server/oauth-microsoft/",
- "Mcp\\Example\\Server\\SchemaShowcase\\": "examples/server/schema-showcase/",
- "Mcp\\Tests\\": "tests/"
- },
- "classmap": [
- "tests/Unit/Capability/Discovery/Fixtures/AlternativeFileNameToolHandler.class.inc"
- ]
- },
- "config": {
- "allow-plugins": {
- "php-http/discovery": false,
- "phpdocumentor/shim": true
- },
- "sort-packages": true
- }
-}
diff --git a/docs/authorization.md b/docs/authorization.md
deleted file mode 100644
index 184eb7e0..00000000
--- a/docs/authorization.md
+++ /dev/null
@@ -1,448 +0,0 @@
-# Authorization
-
-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
-delegate the OAuth flow to your upstream Identity Provider. **It is not an authorization server
-and it does not issue tokens.**
-
-| Role | What it does | Status | Scope |
-|------|--------------|--------|-------|
-| Resource Server | Validates incoming bearer tokens, serves Protected Resource Metadata (RFC 9728), emits `WWW-Authenticate` | Supported (`AuthorizationMiddleware`, `JwtTokenValidator`, `ProtectedResourceMetadata`) | **In scope** |
-| Delegation / proxy to an upstream AS | Forwards `/authorize` and `/token` to your existing IdP | Supported (`OAuthProxyMiddleware`) | **In scope — delegation only** |
-| Authorization Server / Identity Provider | Mints its own tokens, registers clients, runs login and consent | Not implemented | **Out of scope: being an authorization server / issuing tokens is out of scope** |
-
-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).
-
-## Overview
-
-Authorization in MCP is implemented at the transport level using PSR-15 middleware. The SDK provides:
-
-- **AuthorizationMiddleware** - PSR-15 middleware that enforces bearer token authentication
-- **ProtectedResourceMetadataMiddleware** - Serves RFC 9728 metadata at well-known endpoints
-- **OAuthProxyMiddleware** - Delegates OAuth flows (`/authorize`, `/token`) to your upstream IdP; the SDK never issues tokens itself
-- **OAuthRequestMetaMiddleware** - Bridges HTTP OAuth attributes to JSON-RPC request meta
-- **JwtTokenValidator** - Validates JWT tokens using JWKS from OAuth 2.0 / OIDC providers
-- **OidcDiscovery** - Discovers authorization server metadata from well-known endpoints
-
-```
-┌─────────────┐ ┌────────────────────┐ ┌─────────────────┐
-│ MCP Client │────▶│ AuthorizationMiddleware │────▶│ MCP Handlers │
-└─────────────┘ └────────────────────┘ └─────────────────┘
- │ │
- │ │ Validate JWT
- ▼ ▼
-┌─────────────┐ ┌─────────────────┐
-│ Auth Server │◀────│ JwtTokenValidator│
-│ (Keycloak, │ │ + JWKS │
-│ Entra ID) │ └─────────────────┘
-└─────────────┘
-```
-
-## Quick Start
-
-```php
-use Mcp\Server;
-use Mcp\Server\Transport\Http\Middleware\AuthorizationMiddleware;
-use Mcp\Server\Transport\Http\Middleware\ProtectedResourceMetadataMiddleware;
-use Mcp\Server\Transport\Http\OAuth\JwksProvider;
-use Mcp\Server\Transport\Http\OAuth\JwtTokenValidator;
-use Mcp\Server\Transport\Http\OAuth\OidcDiscovery;
-use Mcp\Server\Transport\Http\OAuth\ProtectedResourceMetadata;
-use Mcp\Server\Transport\StreamableHttpTransport;
-
-// 1. Set up OIDC discovery and JWKS provider
-$discovery = new OidcDiscovery();
-$jwksProvider = new JwksProvider($discovery);
-
-// 2. Create JWT validator for your OAuth provider
-$validator = new JwtTokenValidator(
- issuer: 'https://auth.example.com/realms/mcp',
- audience: 'mcp-server',
- jwksProvider: $jwksProvider,
-);
-
-// 3. Create Protected Resource Metadata (RFC 9728)
-$metadata = new ProtectedResourceMetadata(
- authorizationServers: ['https://auth.example.com/realms/mcp'],
- scopesSupported: ['mcp:read', 'mcp:write'],
-);
-
-// 4. Create middleware stack
-$authMiddleware = new AuthorizationMiddleware(
- validator: $validator,
- resourceMetadata: $metadata,
-);
-
-$metadataMiddleware = new ProtectedResourceMetadataMiddleware(
- metadata: $metadata,
-);
-
-// 5. Create transport with middleware
-$transport = new StreamableHttpTransport(
- $request,
- middlewares: [$metadataMiddleware, $authMiddleware],
-);
-
-// 6. Run server
-$server = Server::builder()
- ->setServerInfo('Protected MCP Server', '1.0.0')
- ->setDiscovery(__DIR__)
- ->build();
-
-$response = $server->run($transport);
-```
-
-## Components
-
-### AuthorizationMiddleware
-
-The main middleware that enforces authentication:
-
-```php
-$middleware = new AuthorizationMiddleware(
- validator: $validator, // AuthorizationTokenValidatorInterface
- resourceMetadata: $metadata, // ProtectedResourceMetadata instance
- responseFactory: null, // PSR-17 (auto-discovered)
-);
-```
-
-**Behavior:**
-
-| Request | Response |
-|---------|----------|
-| Missing Authorization header | 401 with `WWW-Authenticate: Bearer resource_metadata="..."` |
-| Invalid/expired token | 401 with error details |
-| Valid token | Passes to next handler with OAuth attributes on request |
-
-### ProtectedResourceMetadataMiddleware
-
-Serves Protected Resource Metadata at configured well-known paths:
-
-```php
-$metadataMiddleware = new ProtectedResourceMetadataMiddleware(
- metadata: $metadata, // ProtectedResourceMetadata instance
- responseFactory: null, // PSR-17 (auto-discovered)
- streamFactory: null, // PSR-17 (auto-discovered)
-);
-```
-
-### JwtTokenValidator
-
-Validates JWT access tokens:
-
-```php
-$validator = new JwtTokenValidator(
- issuer: 'https://auth.example.com', // Expected issuer claim
- audience: 'mcp-server', // Expected audience (string or array)
- jwksProvider: $jwksProvider, // JwksProviderInterface
- jwksUri: null, // Explicit JWKS URI (auto-discovered)
- algorithms: ['RS256', 'RS384'], // Allowed algorithms
- scopeClaim: 'scope', // Claim name for scopes
-);
-```
-
-**Request Attributes:**
-
-After successful validation, these attributes are added to the request:
-
-| Attribute | Description |
-|-----------|-------------|
-| `oauth.claims` | All JWT claims as array |
-| `oauth.scopes` | Extracted scopes as array |
-| `oauth.subject` | The `sub` claim |
-| `oauth.client_id` | The `client_id` claim (if present) |
-| `oauth.authorized_party` | The `azp` claim (if present) |
-
-### ProtectedResourceMetadata
-
-Represents RFC 9728 Protected Resource Metadata:
-
-```php
-$metadata = new ProtectedResourceMetadata(
- authorizationServers: [ // Required: authorization server URLs
- 'https://auth.example.com',
- ],
- scopesSupported: [ // Optional: supported scopes
- 'mcp:read',
- 'mcp:write',
- ],
- resource: 'https://mcp.example.com', // Optional: resource identifier
- resourceName: 'My MCP Server', // Optional: human-readable name
- metadataPaths: [ // Paths to serve metadata (default: /.well-known/oauth-protected-resource)
- '/.well-known/oauth-protected-resource',
- ],
- extra: [ // Optional: additional fields
- 'custom_field' => 'value',
- ],
-);
-```
-
-### OidcDiscovery
-
-Discovers OAuth/OIDC server metadata:
-
-```php
-$discovery = new OidcDiscovery(
- httpClient: null, // PSR-18 (auto-discovered)
- requestFactory: null, // PSR-17 (auto-discovered)
- cache: $cache, // PSR-16 cache (optional)
- cacheTtl: 3600, // Cache TTL
-);
-
-// Discover metadata
-$metadata = $discovery->discover('https://auth.example.com/realms/mcp');
-
-// Get specific endpoints
-$jwksUri = $discovery->getJwksUri($issuer);
-$tokenEndpoint = $discovery->getTokenEndpoint($issuer);
-$authEndpoint = $discovery->getAuthorizationEndpoint($issuer);
-```
-
-### JwksProvider
-
-Fetches and caches JWKS key sets:
-
-```php
-$jwksProvider = new JwksProvider(
- discovery: $discovery, // OidcDiscoveryInterface
- httpClient: null, // PSR-18 (auto-discovered)
- requestFactory: null, // PSR-17 (auto-discovered)
- cache: $cache, // PSR-16 cache (optional)
- cacheTtl: 3600, // JWKS cache TTL
-);
-```
-
-## JWT Token Validation
-
-### Keycloak
-
-```php
-$validator = new JwtTokenValidator(
- issuer: 'https://keycloak.example.com/realms/mcp',
- audience: 'mcp-server',
- jwksProvider: $jwksProvider,
-);
-```
-
-### Microsoft Entra ID (Azure AD)
-
-```php
-$tenantId = 'your-tenant-id';
-$clientId = 'your-client-id';
-
-$validator = new JwtTokenValidator(
- issuer: "https://login.microsoftonline.com/{$tenantId}/v2.0",
- audience: $clientId,
- jwksProvider: $jwksProvider,
-);
-```
-
-### Auth0
-
-```php
-$validator = new JwtTokenValidator(
- issuer: 'https://your-tenant.auth0.com/',
- audience: 'https://api.example.com',
- jwksProvider: $jwksProvider,
-);
-```
-
-### Okta
-
-```php
-$validator = new JwtTokenValidator(
- issuer: 'https://your-org.okta.com/oauth2/default',
- audience: 'api://default',
- jwksProvider: $jwksProvider,
-);
-```
-
-## Protected Resource Metadata
-
-The `ProtectedResourceMetadataMiddleware` serves Protected Resource Metadata at configured paths, enabling clients to discover the authorization server:
-
-```json
-{
- "authorization_servers": ["https://auth.example.com/realms/mcp"],
- "scopes_supported": ["mcp:read", "mcp:write"],
- "resource": "https://mcp.example.com/mcp"
-}
-```
-
-Clients request this from `/.well-known/oauth-protected-resource` before authenticating.
-
-### WWW-Authenticate Header
-
-On 401 responses, the middleware includes:
-
-```
-WWW-Authenticate: Bearer resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource",
- scope="mcp:read mcp:write"
-```
-
-## Custom Token Validators
-
-Implement `AuthorizationTokenValidatorInterface` for custom validation:
-
-```php
-use Mcp\Server\Transport\Http\OAuth\AuthorizationTokenValidatorInterface;
-use Mcp\Server\Transport\Http\OAuth\AuthorizationResult;
-
-final class ApiKeyValidator implements AuthorizationTokenValidatorInterface
-{
- public function __construct(
- private array $validKeys,
- ) {}
-
- public function validate(string $accessToken): AuthorizationResult
- {
- if (!isset($this->validKeys[$accessToken])) {
- return AuthorizationResult::unauthorized(
- 'invalid_token',
- 'Unknown API key'
- );
- }
-
- $keyInfo = $this->validKeys[$accessToken];
-
- return AuthorizationResult::allow([
- 'api_key.name' => $keyInfo['name'],
- 'api_key.scopes' => $keyInfo['scopes'],
- ]);
- }
-}
-
-// Usage
-$validator = new ApiKeyValidator([
- 'sk_live_abc123' => ['name' => 'Production', 'scopes' => ['read', 'write']],
-]);
-```
-
-### AuthorizationResult
-
-Factory methods for different outcomes:
-
-```php
-// Allow access with attributes
-AuthorizationResult::allow(['user_id' => '123']);
-
-// Deny - missing/invalid token (401)
-AuthorizationResult::unauthorized('invalid_token', 'Token expired');
-
-// Deny - valid token but insufficient permissions (403)
-AuthorizationResult::forbidden('insufficient_scope', 'Requires admin scope', ['admin']);
-
-// Deny - malformed request (400)
-AuthorizationResult::badRequest('invalid_request', 'Malformed header');
-```
-
-## Scope-Based Access Control
-
-### Checking Scopes in Handlers
-
-```php
-#[McpTool(name: 'admin_action')]
-public function adminAction(RequestContext $context): array
-{
- $scopes = $context->getRequest()?->getAttribute('oauth.scopes') ?? [];
-
- if (!in_array('mcp:admin', $scopes, true)) {
- throw new \RuntimeException('Admin scope required');
- }
-
- // Perform admin action
- return ['status' => 'success'];
-}
-```
-
-### Using JwtTokenValidator::requireScopes
-
-```php
-// In a custom middleware or handler
-$result = $validator->validate($token);
-
-if ($result->isAllowed()) {
- // Check for specific scopes
- $result = $validator->requireScopes($result, ['mcp:write']);
-}
-
-if (!$result->isAllowed()) {
- // Handle insufficient scope (returns 403)
-}
-```
-
-## Examples
-
-Complete working examples are available in the `examples/server/` directory:
-
-### Keycloak Example
-
-```bash
-cd examples/server/oauth-keycloak
-docker-compose up -d
-
-# Test credentials: demo / demo123
-```
-
-See [oauth-keycloak/README.md](../examples/server/oauth-keycloak/README.md)
-
-### Microsoft Entra ID Example
-
-```bash
-cd examples/server/oauth-microsoft
-cp env.example .env
-# Edit .env with your Azure credentials
-docker-compose up -d
-```
-
-See [oauth-microsoft/README.md](../examples/server/oauth-microsoft/README.md)
-
-## Security Considerations
-
-1. **Always use HTTPS** in production for token transmission
-2. **Validate audience claims** to prevent token confusion attacks
-3. **Use short-lived tokens** and implement token refresh
-4. **Cache JWKS** to reduce latency but allow for key rotation
-5. **Never log tokens** - log only non-sensitive claims like subject
-6. **Validate scopes** before performing sensitive operations
-
-## Troubleshooting
-
-### "Invalid issuer" error
-
-The `iss` claim in the token must exactly match the configured issuer URL, including trailing slashes.
-
-### "Invalid audience" error
-
-Check the `aud` claim matches your configured audience. Some providers use the client ID, others use a custom URI.
-
-### JWKS fetch timeout
-
-- Ensure network connectivity to the authorization server
-- Consider using a cache to reduce dependency on the auth server
-- Check firewall rules allow outbound HTTPS
-
-### Token expired
-
-- Check clock synchronization between servers
-- Tokens typically have a 5-minute clock skew tolerance
-- Ensure clients refresh tokens before expiration
diff --git a/docs/client.md b/docs/client.md
deleted file mode 100644
index 962397c8..00000000
--- a/docs/client.md
+++ /dev/null
@@ -1,873 +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
-
-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();
-```
-
-#### 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
-
-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/events.md b/docs/events.md
deleted file mode 100644
index ebd70ed2..00000000
--- a/docs/events.md
+++ /dev/null
@@ -1,103 +0,0 @@
-# Events
-
-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\Server;
-use Symfony\Component\EventDispatcher\EventDispatcher;
-
-$dispatcher = new EventDispatcher();
-
-// Register your listeners
-$dispatcher->addListener(RequestEvent::class, function (RequestEvent $event) {
- // Handle any incoming request
- if ($event->getMethod() === 'tools/call') {
- // Handle tool call requests specifically
- }
-});
-
-$server = Server::builder()
- ->setEventDispatcher($dispatcher)
- ->build();
-```
-
-## Protocol Events
-
-The SDK dispatches 4 broad event types at the protocol level, allowing you to observe and modify all server operations:
-
-### RequestEvent
-
-**Dispatched**: When any request is received from the client, before it's processed by handlers.
-
-**Properties**:
-- `getRequest(): Request` - The incoming request
-- `setRequest(Request $request): void` - Modify the request before processing
-- `getSession(): SessionInterface` - The current session
-- `getMethod(): string` - Convenience method to get the request method
-
-### ResponseEvent
-
-**Dispatched**: When a successful response is ready to be sent to the client, after handler execution.
-
-**Properties**:
-- `getResponse(): Response` - The response being sent
-- `setResponse(Response $response): void` - Modify the response before sending
-- `getRequest(): Request` - The original request
-- `getSession(): SessionInterface` - The current session
-- `getMethod(): string` - Convenience method to get the request method
-
-### ErrorEvent
-
-**Dispatched**: When an error occurs during request processing.
-
-**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)
-- `getThrowable(): ?\Throwable` - The exception that caused the error (if any)
-- `getSession(): SessionInterface` - The current session
-
-### NotificationEvent
-
-**Dispatched**: When a notification is received from the client, before it's processed by handlers.
-
-**Properties**:
-- `getNotification(): Notification` - The incoming notification
-- `setNotification(Notification $notification): void` - Modify the notification before processing
-- `getSession(): SessionInterface` - The current session
-- `getMethod(): string` - Convenience method to get the notification method
-
-## List Change Events
-
-These events are dispatched when the lists of available capabilities change:
-
-| Event | Description |
-|------------------------------------|------------------------------------------------------------------|
-| `ToolListChangedEvent` | Dispatched when the list of available tools changes |
-| `ResourceListChangedEvent` | Dispatched when the list of available resources changes |
-| `ResourceTemplateListChangedEvent` | Dispatched when the list of available resource templates changes |
-| `PromptListChangedEvent` | Dispatched when the list of available prompts changes |
-
-These events carry no data and are used to notify clients that they should refresh their capability lists.
-
-```php
-use Mcp\Event\ToolListChangedEvent;
-
-$dispatcher->addListener(ToolListChangedEvent::class, function (ToolListChangedEvent $event) {
- $logger->info('Tool list has changed, clients should refresh');
-});
-```
diff --git a/docs/examples.md b/docs/examples.md
deleted file mode 100644
index 14e97fde..00000000
--- a/docs/examples.md
+++ /dev/null
@@ -1,520 +0,0 @@
-# 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.
-
-## 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
-can be run directly without additional setup.
-
-### Prerequisites
-
-```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
-
-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
-
-# Run with debugging enabled
-npx @modelcontextprotocol/inspector -e DEBUG=1 -e FILE_LOG=1 php examples/discovery-calculator/server.php
-
-# Or configure the script path in your MCP client
-# Path: php examples/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/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/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
-```
-
-**Usage:**
-```bash
-# Interactive testing
-npx @modelcontextprotocol/inspector php examples/discovery-calculator/server.php
-
-# Or configure in MCP client: php examples/discovery-calculator/server.php
-```
-
-### Explicit Registration
-
-**File**: `examples/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/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/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/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/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`
-
-### Discovery User Profile
-
-**File**: `examples/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/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/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/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/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:**
-- Server-to-client elicitation requests
-- 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']
-);
-
-// Send elicitation request
-$result = $client->elicit(
- message: 'Please provide your reservation details',
- requestedSchema: $schema
-);
-
-// Handle response
-if ($result->isAccepted()) {
- $data = $result->content; // User-provided data
-} elseif ($result->isDeclined() || $result->isCancelled()) {
- // User declined or cancelled
-}
-```
-
-**Important Notes:**
-- Elicitation requires a session store (e.g., `FileSessionStore`)
-- Check client capabilities with `supportsElicitation()` before sending requests
-- 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
-```
-
-**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](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.
-
-## 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
-php -S localhost:8000 examples/server/http-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
-
-**File**: `examples/client/http_client_communication.php`
-
-**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
-
-**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.
diff --git a/docs/extensions.md b/docs/extensions.md
deleted file mode 100644
index f1817026..00000000
--- a/docs/extensions.md
+++ /dev/null
@@ -1,110 +0,0 @@
-# Protocol Extensions
-
-MCP protocol extensions advertise additional, optional capabilities during the initialize handshake.
-A server opts in via `Builder::enableExtension()`:
-
-```php
-use Mcp\Schema\Extension\Apps\McpApps;
-use Mcp\Server;
-
-$server = Server::builder()
- ->setServerInfo('My Server', '1.0.0')
- ->enableExtension(new McpApps())
- ->build();
-```
-
-Pass one or more `ServerExtensionInterface` instances; multiple extensions can
-be enabled in a single call. Enabling the same extension twice throws a
-`LogicException`.
-
-> Note: extensions enabled via `enableExtension()` are merged into the
-> `extensions` capability even when you supply your own `ServerCapabilities` via
-> `setCapabilities()`. An enabled extension overrides any entry under the same
-> id already present in those capabilities.
-
-## MCP Apps (`io.modelcontextprotocol/ui`)
-
-The [MCP Apps extension][ext-apps] lets servers expose interactive HTML UIs as
-resources. Clients that support it render them in sandboxed iframes and bridge
-tool calls between the iframe (the *View*) and the server via the host.
-
-A UI consists of two pieces wired together by `_meta.ui`:
-
-1. **A resource** with URI scheme `ui://` and MIME type
- `text/html;profile=mcp-app`, returning the HTML body.
-2. **A tool** linked to that resource via `UiToolMeta`, so the client knows to
- open the UI when the tool is invoked.
-
-```php
-use Mcp\Schema\Content\TextResourceContents;
-use Mcp\Schema\Extension\Apps\McpApps;
-use Mcp\Schema\Extension\Apps\ToolVisibility;
-use Mcp\Schema\Extension\Apps\UiResourceContentMeta;
-use Mcp\Schema\Extension\Apps\UiResourceCsp;
-use Mcp\Schema\Extension\Apps\UiResourcePermissions;
-use Mcp\Schema\Extension\Apps\UiToolMeta;
-
-$server = Server::builder()
- ->enableExtension(new McpApps())
- ->addResource(
- fn () => new TextResourceContents(
- uri: 'ui://my-app',
- mimeType: McpApps::MIME_TYPE,
- text: file_get_contents(__DIR__.'/app.html'),
- meta: ['ui' => new UiResourceContentMeta(
- csp: new UiResourceCsp(connectDomains: ['https://api.example.com']),
- permissions: new UiResourcePermissions(geolocation: true),
- prefersBorder: true,
- )],
- ),
- 'ui://my-app',
- mimeType: McpApps::MIME_TYPE,
- meta: ['ui' => McpApps::resourceMarker()],
- )
- ->addTool(
- $myToolHandler,
- 'my_tool',
- meta: ['ui' => new UiToolMeta(
- resourceUri: 'ui://my-app',
- visibility: [ToolVisibility::Model, ToolVisibility::App],
- )],
- )
- ->build();
-```
-
-Note the two distinct `_meta.ui` shapes: the resource *descriptor* (its
-`resources/list` entry) carries only an empty marker — `McpApps::resourceMarker()` —
-flagging it as an MCP App, while the resource *content* returned by `resources/read`
-carries the structured `UiResourceContentMeta` with the actual CSP and permission
-configuration.
-
-### Server-side DTOs
-
-| Class | Purpose |
-| --- | --- |
-| `McpApps` | Extension marker; provides `EXTENSION_ID`, `MIME_TYPE`, `URI_SCHEME` constants. |
-| `UiToolMeta` | Tool `_meta.ui` payload: `resourceUri` + `visibility`. |
-| `ToolVisibility` | Enum: `Model`, `App`. |
-| `UiResourceContentMeta` | Resource content `_meta.ui`: `csp`, `permissions`, `domain`, `prefersBorder`. |
-| `UiResourceCsp` | CSP allow-lists: `connectDomains`, `resourceDomains`, `frameDomains`, `baseUriDomains`. |
-| `UiResourcePermissions` | Sandbox permissions: `camera`, `microphone`, `geolocation`, `clipboardWrite`. |
-
-### Writing the HTML view
-
-The View and host exchange `JSONRPCMessage` **objects** (not JSON strings) via
-`window.parent.postMessage`. Before the host forwards `tools/call`,
-`tool-input`, or `tool-result`, the View must complete the spec-mandated
-handshake:
-
-1. View → Host: `ui/initialize` request
-2. Host → View: response with `hostCapabilities`, `hostInfo`, `hostContext`
-3. View → Host: `ui/notifications/initialized`
-4. View → Host: `ui/notifications/size-changed` whenever the iframe wants to
- resize
-
-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).
-
-[ext-apps]: https://github.com/modelcontextprotocol/ext-apps
diff --git a/docs/index.md b/docs/index.md
deleted file mode 100644
index 91162290..00000000
--- a/docs/index.md
+++ /dev/null
@@ -1,11 +0,0 @@
-# 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.
-- [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.
diff --git a/docs/mcp-elements.md b/docs/mcp-elements.md
deleted file mode 100644
index eb76ed0c..00000000
--- a/docs/mcp-elements.md
+++ /dev/null
@@ -1,890 +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
-
-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/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/server-client-communication.md b/docs/server-client-communication.md
deleted file mode 100644
index 151d5312..00000000
--- a/docs/server-client-communication.md
+++ /dev/null
@@ -1,118 +0,0 @@
-# Client Communication
-
-MCP supports various ways a server can communicate back to a server on top of the main request-response flow.
-
-## Table of Contents
-
-- [ClientGateway](#client-gateway)
-- [Sampling](#sampling)
-- [Logging](#logging)
-- [Notification](#notification)
-- [Progress](#progress)
-
-## 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`.
-
-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:
-
-```php
-use Mcp\Capability\Attribute\McpTool;
-use Mcp\Server\RequestContext;
-
-class MyService
-{
- #[McpTool(name: 'my_tool', description: 'My Tool Description')]
- public function myTool(RequestContext $context): string
- {
- $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:
-
-```php
-use Mcp\Schema\Enum\ProtocolVersion;
-
-if ($context->getProtocolVersion()->isAtLeast(ProtocolVersion::V2026_07_28)) {
- // e.g. a bare list is only valid as `structuredContent` from this revision on
-}
-```
-
-## Sampling
-
-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:
-
-```php
-$result = $clientGateway->sample('Roses are red, violets are', 350, 90, ['temperature' => 0.5]);
-```
-
-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.
-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`,
- `stopSequences`, `metadata`, `tools`, and `toolChoice`
-
-Both `tools`/`toolChoice` and `includeContext` are gated on what the client advertised, so check before sending:
-
-```php
-if ($clientGateway->supportsSamplingTools()) {
- $result = $clientGateway->sample($messages, options: ['tools' => $tools]);
-}
-```
-
-A server **must not** send `tools` or `toolChoice` to a client that did not advertise `sampling.tools`. The
-`includeContext` values other than `none` are soft-deprecated and should only be sent when the client advertises
-`sampling.context` — `supportsSamplingContext()` reports that one.
-
-### Tool loops
-
-When the model wants to call a tool, the result comes back with `stopReason: 'toolUse'` and one or more
-`ToolUseContent` blocks. Execute them, then send a follow-up request with the assistant's message and a user message
-carrying a matching `ToolResultContent` for every `ToolUseContent`:
-
-```php
-$messages[] = new SamplingMessage(Role::Assistant, $result->content);
-$messages[] = new SamplingMessage(Role::User, [new ToolResultContent($toolUse->id, [new TextContent($output)])]);
-```
-
-The specification is strict about the shape of that exchange: tool results may not be mixed with other content in a
-message, and every tool use must be answered before the conversation continues. `sample()` checks these rules before
-sending and throws an `InvalidArgumentException` rather than letting the client reject the request with `-32602`.
-Use `$result->getContentBlocks()` to iterate the response regardless of whether it holds one block or a list.
-
-[Find more details to sampling payload in the specification.](https://modelcontextprotocol.io/specification/2025-11-25/client/sampling#protocol-messages)
-
-## Logging
-
-The [Logging](https://modelcontextprotocol.io/specification/2025-06-18/server/utilities/logging) utility enables servers
-to send structured log messages as notification to clients:
-
-```php
-use Mcp\Schema\Enum\LoggingLevel;
-
-$clientGateway->log(LoggingLevel::Warning, 'The end is near.');
-```
-
-## Progress
-
-With a [Progress](https://modelcontextprotocol.io/specification/2025-06-18/basic/utilities/progress#progress)
-notification a server can update a client while an operation is ongoing:
-
-```php
-$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
-to the client to:
-
-```php
-$clientGateway->notify($yourNotification);
-```
diff --git a/docs/transports.md b/docs/transports.md
deleted file mode 100644
index 4caee513..00000000
--- a/docs/transports.md
+++ /dev/null
@@ -1,548 +0,0 @@
-# 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
-
-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.
-
-```php
-use Psr\Http\Message\ServerRequestInterface;
-
-// PSR-17 factories are automatically discovered
-$transport = new StreamableHttpTransport(
- request: $serverRequest, // PSR-7 server request
- responseFactory: null, // Optional: PSR-17 response factory (auto-discovered if null)
- streamFactory: null, // Optional: PSR-17 stream factory (auto-discovered if null)
- logger: $logger // Optional PSR-3 logger
-);
-```
-
-### 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.
-- **`streamFactory`** (optional): `StreamFactoryInterface` - PSR-17 factory for creating response body streams. Auto-discovered if not provided.
-- **`logger`** (optional): `LoggerInterface` - PSR-3 logger for debugging. Defaults to `NullLogger`.
-- **`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
-
-The transport automatically discovers PSR-17 factory implementations from these popular packages:
-
-- `nyholm/psr7`
-- `guzzlehttp/psr7`
-- `slim/psr7`
-- `laminas/laminas-diactoros`
-- And other PSR-17 compatible implementations
-
-```bash
-# Install any PSR-17 package - discovery works automatically
-composer require nyholm/psr7
-```
-
-If auto-discovery fails or you want to use a specific implementation, you can pass factories explicitly:
-
-```php
-use Nyholm\Psr7\Factory\Psr17Factory;
-
-$psr17Factory = new Psr17Factory();
-$transport = new StreamableHttpTransport($request, $psr17Factory, $psr17Factory);
-```
-
-### Default Middleware
-
-When the `middleware` argument is omitted (or set to `null`), the transport installs a secure default stack:
-
-| Order | Middleware | Purpose |
-|-------|------------|---------|
-| 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.
-$transport = new StreamableHttpTransport($request);
-```
-
-The default stack can be inspected and recomposed via the public factory:
-
-```php
-$middleware = StreamableHttpTransport::defaultMiddleware();
-```
-
-### 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:
-
-```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
-$transport = new StreamableHttpTransport(
- $request,
- middleware: [
- new CorsMiddleware(allowedOrigins: ['https://myapp.com']),
- new DnsRebindingProtectionMiddleware(),
- new ProtocolVersionMiddleware(),
- ],
-);
-
-// Allow all origins (development only)
-$transport = new StreamableHttpTransport(
- $request,
- middleware: [
- new CorsMiddleware(allowedOrigins: ['*']),
- new DnsRebindingProtectionMiddleware(),
- new ProtocolVersionMiddleware(),
- ],
-);
-```
-
-When the allowlist is a concrete set of origins (not `['*']`), `CorsMiddleware` automatically adds `Vary: Origin`
-so shared caches/CDNs do not serve a response generated for one origin to a request from another.
-
-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.
-
-### 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:
-
-```php
-use Mcp\Server\Transport\Http\Middleware\DnsRebindingProtectionMiddleware;
-
-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
-
-`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
-legacy clients do not send it.
-
-```php
-use Mcp\Schema\Enum\ProtocolVersion;
-use Mcp\Server\Transport\Http\Middleware\ProtocolVersionMiddleware;
-
-// Only accept the latest spec version
-new ProtocolVersionMiddleware(supportedVersions: [ProtocolVersion::V2025_11_25]);
-```
-
-The default set is `ProtocolVersion::handshakeVersions()` — every revision the server can actually negotiate over
-`initialize`, rather than every revision the enum declares. A request without the header is treated as
-`ProtocolVersion::DEFAULT_HEADER_VERSION` (`2025-03-26`), the revision that introduced both Streamable HTTP and 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
-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.
-
-### 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
-message parsing.
-
-```php
-use Mcp\Server\Transport\StreamableHttpTransport;
-
-// Raise the cap to 16 MiB
-$transport = new StreamableHttpTransport($request, maxBodyBytes: 16 * 1024 * 1024);
-```
-
-When the request stream advertises a size, the transport rejects it up-front. Otherwise (e.g. chunked transfer with
-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
-
-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);
-```
-
-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`
-entries (the existing per-message error contract), not parse errors or crashes. A `maxBatchSize` below `1` throws
-`InvalidArgumentException`.
-
-### 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
-short-circuited ones:
-
-```php
-use Mcp\Server\Transport\StreamableHttpTransport;
-use Psr\Http\Message\ResponseFactoryInterface;
-use Psr\Http\Message\ResponseInterface;
-use Psr\Http\Message\ServerRequestInterface;
-use Psr\Http\Server\MiddlewareInterface;
-use Psr\Http\Server\RequestHandlerInterface;
-
-final class AuthMiddleware implements MiddlewareInterface
-{
- public function __construct(private ResponseFactoryInterface $responses)
- {
- }
-
- public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
- {
- if (!$request->hasHeader('Authorization')) {
- return $this->responses->createResponse(401);
- }
-
- return $handler->handle($request);
- }
-}
-
-$transport = new StreamableHttpTransport(
- $request,
- logger: $logger,
- middleware: [
- ...StreamableHttpTransport::defaultMiddleware(),
- new AuthMiddleware($responseFactory),
- ],
-);
-```
-
-To selectively drop one default (for example DNS rebinding when running behind a proxy), filter the default list:
-
-```php
-use Mcp\Server\Transport\Http\Middleware\DnsRebindingProtectionMiddleware;
-use Mcp\Server\Transport\StreamableHttpTransport;
-
-$transport = new StreamableHttpTransport(
- $request,
- middleware: [
- ...array_filter(
- StreamableHttpTransport::defaultMiddleware(),
- fn ($m) => !$m instanceof DnsRebindingProtectionMiddleware,
- ),
- new AuthMiddleware($responseFactory),
- ],
-);
-```
-
-Pass `middleware: []` to disable every default and run only your own chain:
-
-```php
-$transport = new StreamableHttpTransport(
- $request,
- 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/examples/client/README.md b/examples/client/README.md
deleted file mode 100644
index 3e3bc092..00000000
--- a/examples/client/README.md
+++ /dev/null
@@ -1,27 +0,0 @@
-# Client Examples
-
-These examples demonstrate how to use the MCP PHP Client SDK.
-
-## STDIO Client
-
-Connects to an MCP server running as a child process:
-
-```bash
-php examples/client/stdio_discovery_calculator.php
-```
-
-## HTTP Client
-
-Connects to an MCP server over HTTP:
-
-```bash
-# First, start an HTTP server
-php -S localhost:8000 examples/server/discovery-calculator/server.php
-
-# Then run the client
-php examples/client/http_discovery_calculator.php
-```
-
-## 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/client/http_client_communication.php b/examples/client/http_client_communication.php
deleted file mode 100644
index 4b6e1661..00000000
--- a/examples/client/http_client_communication.php
+++ /dev/null
@@ -1,132 +0,0 @@
-level->value}] {$n->data}\n";
-});
-
-$samplingRequestHandler = new SamplingRequestHandler(new class implements SamplingCallbackInterface {
- public function __invoke(CreateSamplingMessageRequest $request): CreateSamplingMessageResult
- {
- echo "[SAMPLING] Server requested LLM sampling (max {$request->maxTokens} tokens)\n";
-
- $mockResponse = 'Based on the incident analysis, I recommend: 1) Activate the on-call team, '.
- '2) Isolate affected systems, 3) Begin root cause analysis, 4) Prepare stakeholder communication.';
-
- return new CreateSamplingMessageResult(
- role: Role::Assistant,
- content: new TextContent($mockResponse),
- model: 'mock-gpt-4',
- stopReason: 'endTurn',
- );
- }
-});
-
-$client = Client::builder()
- ->setClientInfo('HTTP Client Communication Test', '1.0.0')
- ->setInitTimeout(30)
- ->setRequestTimeout(120)
- ->setCapabilities(new ClientCapabilities(sampling: true))
- ->addNotificationHandler($loggingNotificationHandler)
- ->addRequestHandler($samplingRequestHandler)
- ->build();
-
-$transport = new HttpTransport(endpoint: $endpoint);
-
-try {
- echo "Connecting to MCP server at {$endpoint}...\n";
- $client->connect($transport);
-
- $serverInfo = $client->getServerInfo();
- echo 'Connected to: '.($serverInfo->name ?? 'unknown')."\n\n";
-
- echo "Available tools:\n";
- $toolsResult = $client->listTools();
- foreach ($toolsResult->tools as $tool) {
- echo " - {$tool->name}\n";
- }
- echo "\n";
-
- echo "Calling 'run_dataset_quality_checks'...\n\n";
- $result = $client->callTool(
- name: 'run_dataset_quality_checks',
- arguments: ['dataset' => 'sales_transactions_q4'],
- onProgress: static function (float $progress, ?float $total, ?string $message) {
- $percent = $total > 0 ? round(($progress / $total) * 100) : '?';
- echo "[PROGRESS {$percent}%] {$message}\n";
- }
- );
-
- echo "\nResult:\n";
- foreach ($result->content as $content) {
- if ($content instanceof TextContent) {
- echo $content->text."\n";
- }
- }
-
- echo "\nCalling 'coordinate_incident_response'...\n\n";
- $result = $client->callTool(
- name: 'coordinate_incident_response',
- arguments: ['incidentTitle' => 'Database connection pool exhausted'],
- onProgress: static function (float $progress, ?float $total, ?string $message) {
- $percent = $total > 0 ? round(($progress / $total) * 100) : '?';
- 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();
-}
diff --git a/examples/client/http_discovery_calculator.php b/examples/client/http_discovery_calculator.php
deleted file mode 100644
index ffcfa67d..00000000
--- a/examples/client/http_discovery_calculator.php
+++ /dev/null
@@ -1,75 +0,0 @@
-setClientInfo('HTTP Example Client', '1.0.0')
- ->setInitTimeout(30)
- ->setRequestTimeout(60)
- ->build();
-
-$transport = new HttpTransport($endpoint);
-
-try {
- echo "Connecting to MCP server at {$endpoint}...\n";
- $client->connect($transport);
-
- echo "Connected! Server info:\n";
- $serverInfo = $client->getServerInfo();
- echo ' Name: '.($serverInfo->name ?? 'unknown')."\n";
- echo ' Version: '.($serverInfo->version ?? 'unknown')."\n\n";
-
- echo "Available tools:\n";
- $toolsResult = $client->listTools();
- foreach ($toolsResult->tools as $tool) {
- echo " - {$tool->name}: {$tool->description}\n";
- }
- echo "\n";
-
- echo "Available resources:\n";
- $resourcesResult = $client->listResources();
- foreach ($resourcesResult->resources as $resource) {
- echo " - {$resource->uri}: {$resource->name}\n";
- }
- echo "\n";
-
- echo "Available prompts:\n";
- $promptsResult = $client->listPrompts();
- foreach ($promptsResult->prompts as $prompt) {
- echo " - {$prompt->name}: {$prompt->description}\n";
- }
- echo "\n";
-} catch (Throwable $e) {
- echo "Error: {$e->getMessage()}\n";
- echo $e->getTraceAsString()."\n";
-} finally {
- echo "Disconnecting...\n";
- $client->disconnect();
- echo "Done.\n";
-}
diff --git a/examples/client/stdio_client_communication.php b/examples/client/stdio_client_communication.php
deleted file mode 100644
index 28e41b6d..00000000
--- a/examples/client/stdio_client_communication.php
+++ /dev/null
@@ -1,124 +0,0 @@
-level->value}] {$n->data}\n";
-});
-
-$samplingRequestHandler = new SamplingRequestHandler(new class implements SamplingCallbackInterface {
- public function __invoke(CreateSamplingMessageRequest $request): CreateSamplingMessageResult
- {
- echo "[SAMPLING] Server requested LLM sampling (max {$request->maxTokens} tokens)\n";
-
- $mockResponse = 'Based on the incident analysis, I recommend: 1) Activate the on-call team, '.
- '2) Isolate affected systems, 3) Begin root cause analysis, 4) Prepare stakeholder communication.';
-
- return new CreateSamplingMessageResult(
- role: Role::Assistant,
- content: new TextContent($mockResponse),
- model: 'mock-gpt-4',
- stopReason: 'endTurn',
- );
- }
-});
-
-$client = Client::builder()
- ->setClientInfo('STDIO Client Communication Test', '1.0.0')
- ->setInitTimeout(30)
- ->setRequestTimeout(120)
- ->setCapabilities(new ClientCapabilities(sampling: true))
- ->addNotificationHandler($loggingNotificationHandler)
- ->addRequestHandler($samplingRequestHandler)
- ->build();
-
-$transport = new StdioTransport(
- command: 'php',
- args: [__DIR__.'/../server/client-communication/server.php'],
-);
-
-try {
- echo "Connecting to MCP server...\n";
- $client->connect($transport);
-
- $serverInfo = $client->getServerInfo();
- echo 'Connected to: '.($serverInfo->name ?? 'unknown')."\n\n";
-
- echo "Available tools:\n";
- $toolsResult = $client->listTools();
- foreach ($toolsResult->tools as $tool) {
- echo " - {$tool->name}\n";
- }
- echo "\n";
-
- echo "Calling 'run_dataset_quality_checks'...\n\n";
- $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";
- }
- );
-
- echo "\nResult:\n";
- foreach ($result->content as $content) {
- if ($content instanceof TextContent) {
- echo $content->text."\n";
- }
- }
-
- echo "\nCalling 'coordinate_incident_response'...\n\n";
- $result = $client->callTool(
- name: 'coordinate_incident_response',
- arguments: ['incidentTitle' => 'Database connection pool exhausted'],
- onProgress: static function (float $progress, ?float $total, ?string $message) {
- $percent = $total > 0 ? round(($progress / $total) * 100) : '?';
- 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();
-}
diff --git a/examples/client/stdio_discovery_calculator.php b/examples/client/stdio_discovery_calculator.php
deleted file mode 100644
index 60719735..00000000
--- a/examples/client/stdio_discovery_calculator.php
+++ /dev/null
@@ -1,87 +0,0 @@
-setClientInfo('STDIO Example Client', '1.0.0')
- ->setInitTimeout(30)
- ->setRequestTimeout(60)
- ->build();
-
-$transport = new StdioTransport(
- command: 'php',
- args: [__DIR__.'/../server/discovery-calculator/server.php'],
-);
-
-try {
- echo "Connecting to MCP server...\n";
- $client->connect($transport);
-
- echo "Connected! Server info:\n";
- $serverInfo = $client->getServerInfo();
- echo ' Name: '.($serverInfo->name ?? 'unknown')."\n";
- echo ' Version: '.($serverInfo->version ?? 'unknown')."\n\n";
-
- echo "Available tools:\n";
- $toolsResult = $client->listTools();
- foreach ($toolsResult->tools as $tool) {
- echo " - {$tool->name}: {$tool->description}\n";
- }
- echo "\n";
-
- echo "Calling 'calculate' tool with a=5, b=3, operation='add'...\n";
- $result = $client->callTool('calculate', ['a' => 5, 'b' => 3, 'operation' => 'add']);
- echo 'Result: ';
- foreach ($result->content as $content) {
- if ($content instanceof TextContent) {
- echo $content->text;
- }
- }
- echo "\n\n";
-
- echo "Available resources:\n";
- $resourcesResult = $client->listResources();
- foreach ($resourcesResult->resources as $resource) {
- echo " - {$resource->uri}: {$resource->name}\n";
- }
- echo "\n";
-
- echo "Reading resource 'config://calculator/settings'...\n";
- $resourceContent = $client->readResource('config://calculator/settings');
- foreach ($resourceContent->contents as $content) {
- if ($content instanceof TextResourceContents) {
- echo ' Content: '.$content->text."\n";
- echo ' Mimetype: '.$content->mimeType."\n";
- }
- }
-} catch (Throwable $e) {
- echo "Error: {$e->getMessage()}\n";
- echo $e->getTraceAsString()."\n";
-} finally {
- echo "Disconnecting...\n";
- $client->disconnect();
- echo "Done.\n";
-}
diff --git a/examples/client/stdio_elicitation.php b/examples/client/stdio_elicitation.php
deleted file mode 100644
index 0780a8ec..00000000
--- a/examples/client/stdio_elicitation.php
+++ /dev/null
@@ -1,144 +0,0 @@
-message}\n";
-
- $content = [];
- foreach ($request->requestedSchema->properties as $name => $definition) {
- $default = $this->defaultFor($definition);
- $label = $this->labelFor($definition);
-
- if (null !== $default) {
- $display = is_bool($default) ? ($default ? 'true' : 'false') : (string) $default;
- echo " {$label} [{$display}]: ";
- } else {
- echo " {$label}: ";
- }
-
- $rawInput = fgets(\STDIN);
- $input = false === $rawInput ? '' : trim($rawInput);
- $value = '' === $input ? $default : $this->cast($definition, $input);
-
- $content[$name] = $value;
- }
-
- return new ElicitResult(ElicitAction::Accept, $content);
- }
-
- private function defaultFor(object $definition): mixed
- {
- return match (true) {
- $definition instanceof EnumSchemaDefinition => $definition->default ?? $definition->enum[0],
- $definition instanceof NumberSchemaDefinition => $definition->default ?? $definition->minimum ?? ($definition->integerOnly ? 1 : 1.0),
- $definition instanceof BooleanSchemaDefinition => $definition->default ?? false,
- $definition instanceof StringSchemaDefinition => $definition->default ?? ('date' === $definition->format ? date('Y-m-d') : ''),
- default => null,
- };
- }
-
- private function labelFor(AbstractSchemaDefinition $definition): string
- {
- return $definition->title;
- }
-
- private function cast(object $definition, string $input): mixed
- {
- return match (true) {
- $definition instanceof BooleanSchemaDefinition => filter_var($input, \FILTER_VALIDATE_BOOLEAN),
- $definition instanceof NumberSchemaDefinition => $definition->integerOnly ? (int) $input : (float) $input,
- default => $input,
- };
- }
-});
-
-$client = Client::builder()
- ->setClientInfo('STDIO Elicitation Test', '1.0.0')
- ->setInitTimeout(30)
- ->setRequestTimeout(120)
- ->setCapabilities(new ClientCapabilities(elicitation: true))
- ->addRequestHandler($elicitationRequestHandler)
- ->build();
-
-$transport = new StdioTransport(
- command: 'php',
- args: [__DIR__.'/../server/elicitation/server.php'],
-);
-
-try {
- echo "Connecting to MCP server...\n";
- $client->connect($transport);
-
- $serverInfo = $client->getServerInfo();
- echo 'Connected to: '.($serverInfo->name ?? 'unknown')."\n\n";
-
- echo "Calling 'book_restaurant'...\n";
- $result = $client->callTool(
- name: 'book_restaurant',
- arguments: ['restaurantName' => 'The Test Kitchen'],
- );
-
- echo "\nResult:\n";
- foreach ($result->content as $content) {
- if ($content instanceof TextContent) {
- echo $content->text."\n";
- }
- }
-
- echo "\nCalling 'confirm_action'...\n";
- $result = $client->callTool(
- name: 'confirm_action',
- arguments: ['actionDescription' => 'Delete all temporary files'],
- );
-
- 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();
-}
diff --git a/examples/client/stdio_roots.php b/examples/client/stdio_roots.php
deleted file mode 100644
index b10e7431..00000000
--- a/examples/client/stdio_roots.php
+++ /dev/null
@@ -1,85 +0,0 @@
-setClientInfo('STDIO Roots Test', '1.0.0')
- ->setInitTimeout(30)
- ->setRequestTimeout(120)
- ->setCapabilities(new ClientCapabilities(roots: true, rootsListChanged: true))
- ->addRequestHandler($rootsRequestHandler)
- ->build();
-
-$transport = new StdioTransport(
- command: 'php',
- args: [__DIR__.'/../server/client-communication/server.php'],
-);
-
-echo "Connecting to MCP server...\n";
-$client->connect($transport);
-
-$serverInfo = $client->getServerInfo();
-echo 'Connected to: '.($serverInfo->name ?? 'unknown')."\n\n";
-
-// The server tool asks the client for its roots, which triggers the handler above.
-echo "Calling 'inspect_workspace_roots'...\n";
-$result = $client->callTool(name: 'inspect_workspace_roots');
-
-echo "\nResult:\n";
-foreach ($result->content as $content) {
- if ($content instanceof TextContent) {
- echo $content->text."\n";
- }
-}
-
-// Whenever the client's workspace folders change, notify the server so it can
-// request an updated list via roots/list.
-echo "\nNotifying the server that the roots list changed...\n";
-$client->sendRootsListChanged();
-
-$client->disconnect();
diff --git a/examples/server/README.md b/examples/server/README.md
deleted file mode 100644
index a9326395..00000000
--- a/examples/server/README.md
+++ /dev/null
@@ -1,34 +0,0 @@
-# MCP SDK Examples
-
-This directory contains various examples of how to use the PHP MCP SDK.
-
-You can run the examples with the dependencies already installed in the root directory of the SDK.
-The bootstrapping of the example will choose the used transport based on the SAPI you use.
-
-For running an example, you execute the `server.php` like this:
-```bash
-# For using the STDIO transport:
-php examples/server/discovery-calculator/server.php
-
-# For using the Streamable HTTP transport:
-php -S localhost:8000 examples/server/discovery-userprofile/server.php
-```
-
-You will see debug outputs to help you understand what is happening.
-
-Run with Inspector:
-
-```bash
-npx @modelcontextprotocol/inspector php examples/server/discovery-calculator/server.php
-```
-
-## Debugging
-
-You can enable debug output by setting the `DEBUG` environment variable to `1`, and additionally log to a file by
-setting the `FILE_LOG` environment variable to `1` as well. A `dev.log` file gets written within the example's
-directory.
-
-With the Inspector you can set the environment variables like this:
-```bash
-npx @modelcontextprotocol/inspector -e DEBUG=1 -e FILE_LOG=1 php examples/server/discovery-calculator/server.php
-```
diff --git a/examples/server/bootstrap.php b/examples/server/bootstrap.php
deleted file mode 100644
index cbe3fb5c..00000000
--- a/examples/server/bootstrap.php
+++ /dev/null
@@ -1,95 +0,0 @@
-critical('Uncaught exception: '.$t->getMessage(), ['exception' => $t]);
-
- exit(1);
-});
-
-/**
- * @return TransportInterface|TransportInterface
- */
-function transport(): TransportInterface
-{
- if ('cli' === \PHP_SAPI) {
- return new StdioTransport(logger: logger());
- }
-
- return new StreamableHttpTransport(
- (new Psr17Factory())->createServerRequestFromGlobals(),
- logger: logger(),
- );
-}
-
-function shutdown(ResponseInterface|int $result): never
-{
- if ('cli' === \PHP_SAPI) {
- exit($result);
- }
-
- (new SapiEmitter())->emit($result);
- exit(0);
-}
-
-function logger(): LoggerInterface
-{
- return new class extends AbstractLogger {
- public function log($level, string|Stringable $message, array $context = []): void
- {
- $debug = $_SERVER['DEBUG'] ?? false;
-
- if (!$debug && 'debug' === $level) {
- return;
- }
-
- $exception = $context['exception'] ?? null;
- unset($context['exception']);
-
- $logMessage = sprintf(
- "[%s] %s %s\n",
- strtoupper($level),
- $message,
- ([] === $context || !$debug) ? '' : json_encode($context),
- );
-
- if ($exception instanceof Throwable) {
- $logMessage .= sprintf('> %s', $exception->getMessage())."\n";
- }
-
- if (($_SERVER['FILE_LOG'] ?? false) || !defined('STDERR')) {
- file_put_contents('dev.log', $logMessage, \FILE_APPEND);
- } else {
- fwrite(\STDERR, $logMessage);
- }
- }
- };
-}
-
-function container(): Container
-{
- $container = new Container();
- $container->set(LoggerInterface::class, logger());
-
- return $container;
-}
diff --git a/examples/server/cached-discovery/CachedCalculatorElements.php b/examples/server/cached-discovery/CachedCalculatorElements.php
deleted file mode 100644
index 07e97520..00000000
--- a/examples/server/cached-discovery/CachedCalculatorElements.php
+++ /dev/null
@@ -1,52 +0,0 @@
-info('Starting MCP Cached Discovery Calculator Server...');
-
-$server = Server::builder()
- ->setServerInfo('Cached Discovery Calculator', '1.0.0', 'Calculator with cached discovery for better performance.')
- ->setContainer(container())
- ->setSession(new FileSessionStore(__DIR__.'/sessions'))
- ->setLogger(logger())
- ->setDiscovery(__DIR__, cache: new Psr16Cache(new PhpFilesAdapter(directory: __DIR__.'/cache')))
- ->build();
-
-$result = $server->run(transport());
-
-logger()->info('Server listener stopped gracefully.', ['result' => $result]);
-
-shutdown($result);
diff --git a/examples/server/client-communication/ClientAwareService.php b/examples/server/client-communication/ClientAwareService.php
deleted file mode 100644
index 1e895c48..00000000
--- a/examples/server/client-communication/ClientAwareService.php
+++ /dev/null
@@ -1,105 +0,0 @@
-logger->info('SamplingTool instantiated for sampling example.');
- }
-
- /**
- * Ask the client which workspace folders the server is allowed to operate on.
- *
- * Demonstrates the server side of the "roots" client capability: the tool
- * issues a roots/list request that the client answers from its own handler.
- *
- * @return array{status: string, message: string, roots?: list}
- */
- #[McpTool(name: 'inspect_workspace_roots', description: 'Ask the client for its workspace roots via a roots/list request.')]
- public function inspectWorkspaceRoots(RequestContext $context): array
- {
- $clientGateway = $context->getClientGateway();
-
- if (!$clientGateway->supportsRoots()) {
- return [
- 'status' => 'unsupported',
- 'message' => 'Client does not expose roots. Advertise the "roots" capability and register a ListRootsRequestHandler to let the server discover your workspace folders.',
- ];
- }
-
- $result = $clientGateway->listRoots();
-
- $roots = [];
- foreach ($result->roots as $root) {
- $roots[] = ['uri' => $root->uri, 'name' => $root->name];
- }
-
- $clientGateway->log(LoggingLevel::Info, \sprintf('Client exposed %d root(s).', \count($roots)));
-
- return [
- 'status' => 'ok',
- 'message' => \sprintf('Client exposed %d root(s).', \count($roots)),
- 'roots' => $roots,
- ];
- }
-
- /**
- * @return array{incident: string, recommended_actions: string, model: string}
- */
- #[McpTool(name: 'coordinate_incident_response', description: 'Coordinate an incident response with logging, progress, and sampling.')]
- public function coordinateIncident(RequestContext $context, string $incidentTitle): array
- {
- $clientGateway = $context->getClientGateway();
- $clientGateway->log(LoggingLevel::Warning, \sprintf('Incident triage started: %s', $incidentTitle));
-
- $steps = [
- 'Collecting telemetry',
- 'Assessing scope',
- 'Coordinating responders',
- ];
-
- foreach ($steps as $index => $step) {
- $progress = ($index + 1) / \count($steps);
-
- $clientGateway->progress($progress, 1, $step);
-
- usleep(180_000); // Simulate work being done
- }
-
- $prompt = \sprintf(
- 'Provide a concise response strategy for incident "%s" based on the steps completed: %s.',
- $incidentTitle,
- implode(', ', $steps)
- );
-
- $result = $clientGateway->sample($prompt, 350, 90, ['temperature' => 0.5]);
-
- $recommendation = $result->content instanceof TextContent ? trim((string) $result->content->text) : '';
-
- $clientGateway->log(LoggingLevel::Info, \sprintf('Incident triage completed for %s', $incidentTitle));
-
- return [
- 'incident' => $incidentTitle,
- 'recommended_actions' => $recommendation,
- 'model' => $result->model,
- ];
- }
-}
diff --git a/examples/server/client-communication/server.php b/examples/server/client-communication/server.php
deleted file mode 100644
index 16b4bd56..00000000
--- a/examples/server/client-communication/server.php
+++ /dev/null
@@ -1,62 +0,0 @@
-setServerInfo('Client Communication Demo', '1.0.0')
- ->setLogger(logger())
- ->setContainer(container())
- ->setSession(new FileSessionStore(__DIR__.'/sessions'))
- ->setCapabilities(new ServerCapabilities(logging: true, tools: true))
- ->setDiscovery(__DIR__)
- ->addTool(
- static function (RequestContext $context, string $dataset): array {
- $client = $context->getClientGateway();
- $client->log(LoggingLevel::Info, sprintf('Running quality checks on dataset "%s"', $dataset));
-
- $tasks = [
- 'Validating schema',
- 'Scanning for anomalies',
- 'Reviewing statistical summary',
- ];
-
- foreach ($tasks as $index => $task) {
- $progress = ($index + 1) / count($tasks);
-
- $client->progress(progress: $progress, total: 1, message: $task);
-
- usleep(140_000); // Simulate work being done
- }
-
- $client->log(LoggingLevel::Info, sprintf('Dataset "%s" passed automated checks.', $dataset));
-
- return [
- 'dataset' => $dataset,
- 'status' => 'passed',
- 'notes' => 'No significant integrity issues detected during automated checks.',
- ];
- },
- name: 'run_dataset_quality_checks',
- description: 'Perform dataset quality checks with progress updates and logging.'
- )
- ->build();
-
-$result = $server->run(transport());
-
-shutdown($result);
diff --git a/examples/server/client-logging/LoggingShowcaseHandlers.php b/examples/server/client-logging/LoggingShowcaseHandlers.php
deleted file mode 100644
index 422efaf1..00000000
--- a/examples/server/client-logging/LoggingShowcaseHandlers.php
+++ /dev/null
@@ -1,81 +0,0 @@
-
- */
- #[McpTool(name: 'log_message', description: 'Demonstrates MCP logging with different levels')]
- public function logMessage(RequestContext $context, string $message, string $level): array
- {
- $logger = $context->getClientLogger();
- $logger->info('🚀 Starting log_message tool', [
- 'requested_level' => $level,
- 'message_length' => \strlen($message),
- ]);
-
- switch (strtolower($level)) {
- case 'debug':
- $logger->debug("Debug: $message", ['tool' => 'log_message']);
- break;
- case 'info':
- $logger->info("Info: $message", ['tool' => 'log_message']);
- break;
- case 'notice':
- $logger->notice("Notice: $message", ['tool' => 'log_message']);
- break;
- case 'warning':
- $logger->warning("Warning: $message", ['tool' => 'log_message']);
- break;
- case 'error':
- $logger->error("Error: $message", ['tool' => 'log_message']);
- break;
- case 'critical':
- $logger->critical("Critical: $message", ['tool' => 'log_message']);
- break;
- case 'alert':
- $logger->alert("Alert: $message", ['tool' => 'log_message']);
- break;
- case 'emergency':
- $logger->emergency("Emergency: $message", ['tool' => 'log_message']);
- break;
- default:
- $logger->warning("Unknown level '$level', defaulting to info");
- $logger->info("Info: $message", ['tool' => 'log_message']);
- }
-
- $logger->debug('log_message tool completed successfully');
-
- return [
- 'message' => "Logged message with level: $level",
- 'logged_at' => date('Y-m-d H:i:s'),
- 'level_used' => $level,
- ];
- }
-}
diff --git a/examples/server/client-logging/server.php b/examples/server/client-logging/server.php
deleted file mode 100644
index 3ca523f2..00000000
--- a/examples/server/client-logging/server.php
+++ /dev/null
@@ -1,29 +0,0 @@
-#!/usr/bin/env php
-setServerInfo('Client Logging', '1.0.0', 'Demonstration of MCP logging in capability handlers.')
- ->setContainer(container())
- ->setLogger(logger())
- ->setDiscovery(__DIR__)
- ->build();
-
-$result = $server->run(transport());
-
-logger()->info('Server listener stopped gracefully.', ['result' => $result]);
-
-shutdown($result);
diff --git a/examples/server/combined-registration/DiscoveredElements.php b/examples/server/combined-registration/DiscoveredElements.php
deleted file mode 100644
index c2f93ac3..00000000
--- a/examples/server/combined-registration/DiscoveredElements.php
+++ /dev/null
@@ -1,41 +0,0 @@
-logger->info("Manual tool 'manual_greeter' called for {$user}");
-
- return "Hello {$user}, from manual registration!";
- }
-
- /**
- * Manually registered resource that overrides a discovered one.
- *
- * @return string content
- */
- public function getPriorityConfigManual(): string
- {
- $this->logger->info("Manual resource 'config://priority' read.");
-
- return 'Manual Priority Config: HIGH (overrides discovered)';
- }
-}
diff --git a/examples/server/combined-registration/PreconfiguredGreeter.php b/examples/server/combined-registration/PreconfiguredGreeter.php
deleted file mode 100644
index 8d55abd5..00000000
--- a/examples/server/combined-registration/PreconfiguredGreeter.php
+++ /dev/null
@@ -1,46 +0,0 @@
-addTool([new PreconfiguredGreeter('...', ...), 'greet'], 'instance_greeter')`.
- *
- * Neither the container-less `new $className()` fallback nor the auto-wiring
- * container can build this class, since the required `string $greeting` has no
- * default and is not a resolvable service.
- */
-final class PreconfiguredGreeter
-{
- public function __construct(
- private readonly string $greeting,
- private readonly LoggerInterface $logger,
- ) {
- }
-
- /**
- * A tool registered as a pre-built object instance.
- *
- * @param string $name the name to greet
- *
- * @return string greeting
- */
- public function greet(string $name): string
- {
- $this->logger->info("Instance tool 'instance_greeter' called for {$name}");
-
- return "{$this->greeting}, {$name}!";
- }
-}
diff --git a/examples/server/combined-registration/server.php b/examples/server/combined-registration/server.php
deleted file mode 100644
index 475ed419..00000000
--- a/examples/server/combined-registration/server.php
+++ /dev/null
@@ -1,43 +0,0 @@
-#!/usr/bin/env php
-setServerInfo('Combined HTTP Server', '1.0.0')
- ->setLogger(logger())
- ->setContainer(container())
- ->setSession(new FileSessionStore(__DIR__.'/sessions'))
- ->setDiscovery(__DIR__)
- ->addTool([ManualHandlers::class, 'manualGreeter'])
- ->addTool([$preconfiguredGreeter, 'greet'], 'instance_greeter')
- ->addResource(
- [ManualHandlers::class, 'getPriorityConfigManual'],
- 'config://priority',
- 'priority_config_manual',
- )
- ->build();
-
-$response = $server->run(transport());
-
-shutdown($response);
diff --git a/examples/server/complex-tool-schema/McpEventScheduler.php b/examples/server/complex-tool-schema/McpEventScheduler.php
deleted file mode 100644
index 366c687e..00000000
--- a/examples/server/complex-tool-schema/McpEventScheduler.php
+++ /dev/null
@@ -1,72 +0,0 @@
- confirmation of the scheduled event
- */
- #[McpTool(name: 'schedule_event')]
- public function scheduleEvent(
- string $title,
- string $date,
- EventType $type,
- ?string $time = null,
- EventPriority $priority = EventPriority::Normal,
- ?array $attendees = null,
- bool $sendInvites = true,
- ): array {
- $this->logger->info("Tool 'schedule_event' called", compact('title', 'date', 'type', 'time', 'priority', 'attendees', 'sendInvites'));
-
- // Simulate scheduling logic
- $eventDetails = [
- 'title' => $title,
- 'date' => $date,
- 'type' => $type->value, // Use enum value
- 'time' => $time ?? 'All day',
- 'priority' => $priority->name, // Use enum name
- 'attendees' => $attendees ?? [],
- 'invites_will_be_sent' => ($attendees && $sendInvites),
- ];
-
- // In a real app, this would interact with a calendar service
- $this->logger->info('Event scheduled', ['details' => $eventDetails]);
-
- return [
- 'success' => true,
- 'message' => \sprintf('Event "%s" scheduled successfully for "%s".', $title, $date),
- 'event_details' => $eventDetails,
- ];
- }
-}
diff --git a/examples/server/complex-tool-schema/Model/EventPriority.php b/examples/server/complex-tool-schema/Model/EventPriority.php
deleted file mode 100644
index 97bdb70e..00000000
--- a/examples/server/complex-tool-schema/Model/EventPriority.php
+++ /dev/null
@@ -1,19 +0,0 @@
-setServerInfo('Event Scheduler Server', '1.0.0')
- ->setLogger(logger())
- ->setContainer(container())
- ->setSession(new FileSessionStore(__DIR__.'/sessions'))
- ->setDiscovery(__DIR__)
- ->build();
-
-$response = $server->run(transport());
-
-shutdown($response);
diff --git a/examples/server/custom-dependencies/McpTaskHandlers.php b/examples/server/custom-dependencies/McpTaskHandlers.php
deleted file mode 100644
index affa2a7b..00000000
--- a/examples/server/custom-dependencies/McpTaskHandlers.php
+++ /dev/null
@@ -1,92 +0,0 @@
-logger->info('McpTaskHandlers instantiated with dependencies.');
- }
-
- /**
- * Adds a new task for a given user.
- *
- * @param string $userId the ID of the user
- * @param string $description the task description
- *
- * @return Task the created task details
- */
- #[McpTool(name: 'add_task')]
- public function addTask(string $userId, string $description): array
- {
- $this->logger->info("Tool 'add_task' invoked", ['userId' => $userId]);
-
- return $this->taskRepo->addTask($userId, $description);
- }
-
- /**
- * Lists pending tasks for a specific user.
- *
- * @param string $userId the ID of the user
- *
- * @return Task[] a list of tasks
- */
- #[McpTool(name: 'list_user_tasks')]
- public function listUserTasks(string $userId): array
- {
- $this->logger->info("Tool 'list_user_tasks' invoked", ['userId' => $userId]);
-
- return $this->taskRepo->getTasksForUser($userId);
- }
-
- /**
- * Marks a task as complete.
- *
- * @param int $taskId the ID of the task to complete
- *
- * @return array status of the operation
- */
- #[McpTool(name: 'complete_task')]
- public function completeTask(int $taskId): array
- {
- $this->logger->info("Tool 'complete_task' invoked", ['taskId' => $taskId]);
- $success = $this->taskRepo->completeTask($taskId);
-
- return ['success' => $success, 'message' => $success ? "Task {$taskId} completed." : "Task {$taskId} not found."];
- }
-
- /**
- * Provides current system statistics.
- *
- * @return array system statistics
- */
- #[McpResource(uri: 'stats://system/overview', name: 'system_stats', mimeType: 'application/json')]
- public function getSystemStatistics(): array
- {
- $this->logger->info("Resource 'stats://system/overview' invoked");
-
- return $this->statsService->getSystemStats();
- }
-}
diff --git a/examples/server/custom-dependencies/Service/InMemoryTaskRepository.php b/examples/server/custom-dependencies/Service/InMemoryTaskRepository.php
deleted file mode 100644
index 2c67af62..00000000
--- a/examples/server/custom-dependencies/Service/InMemoryTaskRepository.php
+++ /dev/null
@@ -1,72 +0,0 @@
-
- */
- private array $tasks = [];
- private int $nextTaskId = 1;
-
- public function __construct(
- private readonly LoggerInterface $logger,
- ) {
- // Add some initial tasks
- $this->addTask('user1', 'Buy groceries');
- $this->addTask('user1', 'Write MCP example');
- $this->addTask('user2', 'Review PR');
- }
-
- public function addTask(string $userId, string $description): array
- {
- $task = [
- 'id' => $this->nextTaskId++,
- 'userId' => $userId,
- 'description' => $description,
- 'completed' => false,
- 'createdAt' => date('c'),
- ];
- $this->tasks[$task['id']] = $task;
- $this->logger->info('Task added', ['id' => $task['id'], 'user' => $userId]);
-
- return $task;
- }
-
- public function getTasksForUser(string $userId): array
- {
- return array_values(array_filter($this->tasks, static fn ($task) => $task['userId'] === $userId && !$task['completed']));
- }
-
- public function getAllTasks(): array
- {
- return array_values($this->tasks);
- }
-
- public function completeTask(int $taskId): bool
- {
- if (isset($this->tasks[$taskId])) {
- $this->tasks[$taskId]['completed'] = true;
- $this->logger->info('Task completed', ['id' => $taskId]);
-
- return true;
- }
-
- return false;
- }
-}
diff --git a/examples/server/custom-dependencies/Service/StatsServiceInterface.php b/examples/server/custom-dependencies/Service/StatsServiceInterface.php
deleted file mode 100644
index 85bf9b34..00000000
--- a/examples/server/custom-dependencies/Service/StatsServiceInterface.php
+++ /dev/null
@@ -1,20 +0,0 @@
-
- */
- public function getSystemStats(): array;
-}
diff --git a/examples/server/custom-dependencies/Service/SystemStatsService.php b/examples/server/custom-dependencies/Service/SystemStatsService.php
deleted file mode 100644
index e41b6a24..00000000
--- a/examples/server/custom-dependencies/Service/SystemStatsService.php
+++ /dev/null
@@ -1,34 +0,0 @@
-taskRepository->getAllTasks();
- $completed = \count(array_filter($allTasks, static fn ($task) => $task['completed']));
- $pending = \count($allTasks) - $completed;
-
- return [
- 'total_tasks' => \count($allTasks),
- 'completed_tasks' => $completed,
- 'pending_tasks' => $pending,
- 'server_uptime_seconds' => time() - $_SERVER['REQUEST_TIME_FLOAT'], // Approx uptime for CLI script
- ];
- }
-}
diff --git a/examples/server/custom-dependencies/Service/TaskRepositoryInterface.php b/examples/server/custom-dependencies/Service/TaskRepositoryInterface.php
deleted file mode 100644
index b1d43ce1..00000000
--- a/examples/server/custom-dependencies/Service/TaskRepositoryInterface.php
+++ /dev/null
@@ -1,35 +0,0 @@
-info('Starting MCP Custom Dependencies Server...');
-
-$container = container();
-
-$taskRepo = new InMemoryTaskRepository(logger());
-$container->set(TaskRepositoryInterface::class, $taskRepo);
-
-$statsService = new SystemStatsService($taskRepo);
-$container->set(StatsServiceInterface::class, $statsService);
-
-$server = Server::builder()
- ->setServerInfo('Task Manager Server', '1.0.0')
- ->setContainer($container)
- ->setSession(new FileSessionStore(__DIR__.'/sessions'))
- ->setLogger(logger())
- ->setDiscovery(__DIR__)
- ->build();
-
-$result = $server->run(transport());
-
-logger()->info('Server listener stopped gracefully.', ['result' => $result]);
-
-shutdown($result);
diff --git a/examples/server/custom-method-handlers/CallToolRequestHandler.php b/examples/server/custom-method-handlers/CallToolRequestHandler.php
deleted file mode 100644
index 683d8a03..00000000
--- a/examples/server/custom-method-handlers/CallToolRequestHandler.php
+++ /dev/null
@@ -1,73 +0,0 @@
- */
-class CallToolRequestHandler implements RequestHandlerInterface
-{
- /**
- * @param array $toolDefinitions
- */
- public function __construct(private array $toolDefinitions)
- {
- }
-
- public function supports(Request $request): bool
- {
- return $request instanceof CallToolRequest;
- }
-
- /**
- * @return Response|Error
- */
- public function handle(Request $request, SessionInterface $session): Response|Error
- {
- \assert($request instanceof CallToolRequest);
-
- $name = $request->name;
- $args = $request->arguments;
-
- if (!isset($this->toolDefinitions[$name])) {
- return new Error($request->getId(), Error::METHOD_NOT_FOUND, \sprintf('Tool not found: %s', $name));
- }
-
- try {
- switch ($name) {
- case 'say_hello':
- $greetName = (string) ($args['name'] ?? 'world');
- $result = [new TextContent(\sprintf('Hello, %s!', $greetName))];
- break;
- case 'sum':
- $a = (float) ($args['a'] ?? 0);
- $b = (float) ($args['b'] ?? 0);
- $result = [new TextContent((string) ($a + $b))];
- break;
- default:
- $result = [new TextContent('Unknown tool')];
- }
-
- return new Response($request->getId(), new CallToolResult($result));
- } catch (\Throwable $e) {
- return new Response($request->getId(), new CallToolResult([new TextContent('Tool execution failed')], true));
- }
- }
-}
diff --git a/examples/server/custom-method-handlers/ListToolsRequestHandler.php b/examples/server/custom-method-handlers/ListToolsRequestHandler.php
deleted file mode 100644
index e97ade55..00000000
--- a/examples/server/custom-method-handlers/ListToolsRequestHandler.php
+++ /dev/null
@@ -1,46 +0,0 @@
- */
-class ListToolsRequestHandler implements RequestHandlerInterface
-{
- /**
- * @param array $toolDefinitions
- */
- public function __construct(private array $toolDefinitions)
- {
- }
-
- public function supports(Request $request): bool
- {
- return $request instanceof ListToolsRequest;
- }
-
- /**
- * @return Response
- */
- public function handle(Request $request, SessionInterface $session): Response
- {
- \assert($request instanceof ListToolsRequest);
-
- return new Response($request->getId(), new ListToolsResult(array_values($this->toolDefinitions), null));
- }
-}
diff --git a/examples/server/custom-method-handlers/server.php b/examples/server/custom-method-handlers/server.php
deleted file mode 100644
index 461d9d96..00000000
--- a/examples/server/custom-method-handlers/server.php
+++ /dev/null
@@ -1,72 +0,0 @@
-#!/usr/bin/env php
-info('Starting MCP Custom Method Handlers Server...');
-
-$toolDefinitions = [
- 'say_hello' => new Tool(
- name: 'say_hello',
- title: null,
- inputSchema: [
- 'type' => 'object',
- 'properties' => [
- 'name' => ['type' => 'string', 'description' => 'Name to greet'],
- ],
- 'required' => ['name'],
- ],
- description: 'Greets a user by name.',
- annotations: null,
- ),
- 'sum' => new Tool(
- name: 'sum',
- title: null,
- inputSchema: [
- 'type' => 'object',
- 'properties' => [
- 'a' => ['type' => 'number'],
- 'b' => ['type' => 'number'],
- ],
- 'required' => ['a', 'b'],
- ],
- description: 'Returns a+b.',
- annotations: null,
- ),
-];
-
-$listToolsHandler = new ListToolsRequestHandler($toolDefinitions);
-$callToolHandler = new CallToolRequestHandler($toolDefinitions);
-$capabilities = new ServerCapabilities(tools: true, resources: false, prompts: false);
-
-$server = Server::builder()
- ->setServerInfo('Custom Handlers Server', '1.0.0')
- ->setContainer(container())
- ->setSession(new FileSessionStore(__DIR__.'/sessions'))
- ->setLogger(logger())
- ->setCapabilities($capabilities)
- ->addRequestHandlers([$listToolsHandler, $callToolHandler])
- ->build();
-
-$result = $server->run(transport());
-
-logger()->info('Server listener stopped gracefully.', ['result' => $result]);
-
-shutdown($result);
diff --git a/examples/server/discovery-calculator/McpElements.php b/examples/server/discovery-calculator/McpElements.php
deleted file mode 100644
index cabd3101..00000000
--- a/examples/server/discovery-calculator/McpElements.php
+++ /dev/null
@@ -1,155 +0,0 @@
- 2,
- 'allow_negative' => true,
- ];
-
- public function __construct(
- private readonly LoggerInterface $logger = new NullLogger(),
- ) {
- }
-
- /**
- * Performs a calculation based on the operation.
- *
- * Supports 'add', 'subtract', 'multiply', 'divide'.
- * Obeys the 'precision' and 'allow_negative' settings from the config resource.
- *
- * @param float $a the first operand
- * @param float $b the second operand
- * @param string $operation the operation ('add', 'subtract', 'multiply', 'divide')
- *
- * @return float the result of the calculation
- */
- #[McpTool(
- name: 'calculate',
- icons: [new Icon('https://www.svgrepo.com/show/530644/calculator.svg', 'image/svg+xml', ['any'])],
- )]
- public function calculate(float $a, float $b, string $operation): float
- {
- $this->logger->info(\sprintf('Calculating: %f %s %f', $a, $operation, $b));
-
- $op = strtolower($operation);
-
- switch ($op) {
- case 'add':
- $result = $a + $b;
- break;
- case 'subtract':
- $result = $a - $b;
- break;
- case 'multiply':
- $result = $a * $b;
- break;
- case 'divide':
- if (0 == $b) {
- throw new ToolCallException('Division by zero is not allowed.');
- }
- $result = $a / $b;
- break;
- default:
- throw new ToolCallException("Unknown operation '{$operation}'. Supported: add, subtract, multiply, divide.");
- }
-
- if (!$this->config['allow_negative'] && $result < 0) {
- throw new ToolCallException('Negative results are disabled.');
- }
-
- return round($result, $this->config['precision']);
- }
-
- /**
- * Provides the current calculator configuration.
- * Can be read by clients to understand precision etc.
- *
- * @return Config the configuration array
- */
- #[McpResource(
- uri: 'config://calculator/settings',
- name: 'calculator_config',
- description: 'Current settings for the calculator tool (precision, allow_negative).',
- mimeType: 'application/json',
- icons: [new Icon('https://www.svgrepo.com/show/529867/settings.svg', 'image/svg+xml', ['any'])],
- )]
- public function getConfiguration(): array
- {
- $this->logger->info('Resource config://calculator/settings read.');
-
- return $this->config;
- }
-
- /**
- * Updates a specific configuration setting.
- * Note: This requires more robust validation in a real app.
- *
- * @param string $setting the setting key ('precision' or 'allow_negative')
- * @param mixed $value the new value (int for precision, bool for allow_negative)
- *
- * @return array{
- * success: bool,
- * error?: string,
- * message?: string
- * } success message or error
- */
- #[McpTool(name: 'update_setting')]
- public function updateSetting(string $setting, mixed $value): array
- {
- $this->logger->info(\sprintf('Setting tool called: setting=%s, value=%s', $setting, var_export($value, true)));
- if (!\array_key_exists($setting, $this->config)) {
- return ['success' => false, 'error' => "Unknown setting '{$setting}'."];
- }
-
- if ('precision' === $setting) {
- if (!\is_int($value) || $value < 0 || $value > 10) {
- return ['success' => false, 'error' => 'Invalid precision value. Must be integer between 0 and 10.'];
- }
- $this->config['precision'] = $value;
-
- // In real app, notify subscribers of config://calculator/settings change
- // $registry->notifyResourceChanged('config://calculator/settings');
- return ['success' => true, 'message' => "Precision updated to {$value}."];
- }
-
- if (!\is_bool($value)) {
- // Attempt basic cast for flexibility
- if (\in_array(strtolower((string) $value), ['true', '1', 'yes', 'on'])) {
- $value = true;
- } elseif (\in_array(strtolower((string) $value), ['false', '0', 'no', 'off'])) {
- $value = false;
- } else {
- return ['success' => false, 'error' => 'Invalid allow_negative value. Must be boolean (true/false).'];
- }
- }
- $this->config['allow_negative'] = $value;
-
- // $registry->notifyResourceChanged('config://calculator/settings');
- return ['success' => true, 'message' => 'Allow negative results set to '.($value ? 'true' : 'false').'.'];
- }
-}
diff --git a/examples/server/discovery-calculator/server.php b/examples/server/discovery-calculator/server.php
deleted file mode 100644
index c6d75e4d..00000000
--- a/examples/server/discovery-calculator/server.php
+++ /dev/null
@@ -1,34 +0,0 @@
-#!/usr/bin/env php
-info('Starting MCP Calculator Server...');
-
-$server = Server::builder()
- ->setServerInfo('Calculator', '1.1.0', 'Basic Calculator')
- ->setInstructions('This server supports basic arithmetic operations: add, subtract, multiply, and divide. Send JSON-RPC requests to perform calculations.')
- ->setContainer(container())
- ->setSession(new FileSessionStore(__DIR__.'/sessions'))
- ->setLogger(logger())
- ->setDiscovery(__DIR__)
- ->build();
-
-$result = $server->run(transport());
-
-logger()->info('Server listener stopped gracefully.', ['result' => $result]);
-
-shutdown($result);
diff --git a/examples/server/discovery-userprofile/McpElements.php b/examples/server/discovery-userprofile/McpElements.php
deleted file mode 100644
index c8532544..00000000
--- a/examples/server/discovery-userprofile/McpElements.php
+++ /dev/null
@@ -1,193 +0,0 @@
-
- */
- private array $users = [
- '101' => ['name' => 'Alice', 'email' => 'alice@example.com', 'role' => 'admin'],
- '102' => ['name' => 'Bob', 'email' => 'bob@example.com', 'role' => 'user'],
- '103' => ['name' => 'Charlie', 'email' => 'charlie@example.com', 'role' => 'user'],
- ];
-
- public function __construct(
- private readonly LoggerInterface $logger,
- ) {
- $this->logger->debug('DiscoveryUserProfile McpElements instantiated.');
- }
-
- /**
- * Retrieves the profile data for a specific user.
- *
- * @param string $userId the ID of the user (from URI)
- *
- * @return User user profile data
- *
- * @throws ResourceReadException if the user is not found
- */
- #[McpResourceTemplate(
- uriTemplate: 'user://{userId}/profile',
- name: 'user_profile',
- description: 'Get profile information for a specific user ID.',
- mimeType: 'application/json'
- )]
- public function getUserProfile(
- #[CompletionProvider(values: ['101', '102', '103'])]
- string $userId,
- ): array {
- $this->logger->info('Reading resource: user profile', ['userId' => $userId]);
- if (!isset($this->users[$userId])) {
- throw new ResourceReadException("User not found for ID: {$userId}");
- }
-
- return $this->users[$userId];
- }
-
- /**
- * Retrieves a list of all known user IDs.
- *
- * @return int[] list of user IDs
- */
- #[McpResource(
- uri: 'user://list/ids',
- name: 'user_id_list',
- description: 'Provides a list of all available user IDs.',
- mimeType: 'application/json'
- )]
- public function listUserIds(): array
- {
- $this->logger->info('Reading resource: user ID list');
-
- return array_keys($this->users);
- }
-
- /**
- * Looks up a user and returns a reference to their profile resource.
- *
- * Rather than embedding the full profile (as `resources/read` on
- * `user://{userId}/profile` would), this returns a `resource_link` block
- * pointing at that resource template so the caller can fetch it
- * separately if needed. This mirrors how a tool like a search returning
- * many hits would reference each matching resource by URI instead of
- * inlining every one of them.
- *
- * @param string $userId the ID of the user to look up
- *
- * @return Content[] a short summary plus a resource_link to the user's profile
- */
- #[McpTool(name: 'lookup_user')]
- public function lookupUser(
- #[CompletionProvider(values: ['101', '102', '103'])]
- string $userId,
- ): array {
- $this->logger->info('Executing tool: lookup_user', ['userId' => $userId]);
-
- if (!isset($this->users[$userId])) {
- return [new TextContent("User ID {$userId} not found.")];
- }
-
- $user = $this->users[$userId];
-
- return [
- new TextContent("Found user {$user['name']} ({$user['role']})."),
- new ResourceLink(
- uri: "user://{$userId}/profile",
- name: 'user_profile',
- description: "Full profile for {$user['name']}.",
- mimeType: 'application/json',
- ),
- ];
- }
-
- /**
- * Sends a welcome message to a user.
- * (This is a placeholder - in a real app, it might queue an email).
- *
- * @param string $userId the ID of the user to message
- * @param string|null $customMessage an optional custom message part
- *
- * @return array status of the operation
- */
- #[McpTool(name: 'send_welcome')]
- public function sendWelcomeMessage(string $userId, ?string $customMessage = null): array
- {
- $this->logger->info('Executing tool: send_welcome', ['userId' => $userId]);
- if (!isset($this->users[$userId])) {
- return ['success' => false, 'error' => "User ID {$userId} not found."];
- }
- $user = $this->users[$userId];
- $message = "Welcome, {$user['name']}!";
- if ($customMessage) {
- $message .= ' '.$customMessage;
- }
- // Simulate sending
- $this->logger->info("Simulated sending message to {$user['email']}: {$message}");
-
- return ['success' => true, 'message_sent' => $message];
- }
-
- /**
- * @return array
- */
- #[McpTool(name: 'test_tool_without_params')]
- public function testToolWithoutParams(): array
- {
- return ['success' => true, 'message' => 'Test tool without params'];
- }
-
- /**
- * Generates a prompt to write a bio for a user.
- *
- * @param string $userId the user ID to generate the bio for
- * @param string $tone Desired tone (e.g., 'formal', 'casual').
- *
- * @return array[] prompt messages
- *
- * @throws PromptGetException if user not found
- */
- #[McpPrompt(name: 'generate_bio_prompt')]
- public function generateBio(
- #[CompletionProvider(provider: UserIdCompletionProvider::class)]
- string $userId,
- string $tone = 'professional',
- ): array {
- $this->logger->info('Executing prompt: generate_bio', ['userId' => $userId, 'tone' => $tone]);
- if (!isset($this->users[$userId])) {
- throw new PromptGetException("User not found for bio prompt: {$userId}");
- }
- $user = $this->users[$userId];
-
- return [
- ['role' => 'user', 'content' => "Write a short, {$tone} biography for {$user['name']} (Role: {$user['role']}, Email: {$user['email']}). Highlight their role within the system."],
- ];
- }
-}
diff --git a/examples/server/discovery-userprofile/UserIdCompletionProvider.php b/examples/server/discovery-userprofile/UserIdCompletionProvider.php
deleted file mode 100644
index bee4ffa6..00000000
--- a/examples/server/discovery-userprofile/UserIdCompletionProvider.php
+++ /dev/null
@@ -1,24 +0,0 @@
- str_contains($userId, $currentValue));
- }
-}
diff --git a/examples/server/discovery-userprofile/server.php b/examples/server/discovery-userprofile/server.php
deleted file mode 100644
index 25448188..00000000
--- a/examples/server/discovery-userprofile/server.php
+++ /dev/null
@@ -1,71 +0,0 @@
-#!/usr/bin/env php
-setServerInfo('HTTP User Profiles', '1.0.0')
- ->setLogger(logger())
- ->setContainer(container())
- ->setSession(new FileSessionStore(__DIR__.'/sessions'))
- ->setDiscovery(__DIR__)
- ->addTool(
- static function (float $a, float $b, string $operation = 'add'): array {
- $result = match ($operation) {
- 'add' => $a + $b,
- 'subtract' => $a - $b,
- 'multiply' => $a * $b,
- 'divide' => 0 != $b ? $a / $b : throw new InvalidArgumentException('Cannot divide by zero'),
- default => throw new InvalidArgumentException("Unknown operation: {$operation}"),
- };
-
- return [
- 'operation' => $operation,
- 'operands' => [$a, $b],
- 'result' => $result,
- ];
- },
- name: 'calculator',
- description: 'Perform basic math operations (add, subtract, multiply, divide)'
- )
- ->addResource(
- static function (): array {
- $memoryUsage = memory_get_usage(true);
- $memoryPeak = memory_get_peak_usage(true);
- $uptime = time() - ($_SERVER['REQUEST_TIME_FLOAT'] ?? time());
- $serverSoftware = $_SERVER['SERVER_SOFTWARE'] ?? 'CLI';
-
- return [
- 'server_time' => date('Y-m-d H:i:s'),
- 'uptime_seconds' => $uptime,
- 'memory_usage_mb' => round($memoryUsage / 1024 / 1024, 2),
- 'memory_peak_mb' => round($memoryPeak / 1024 / 1024, 2),
- 'php_version' => \PHP_VERSION,
- 'server_software' => $serverSoftware,
- 'operating_system' => \PHP_OS_FAMILY,
- 'status' => 'healthy',
- ];
- },
- uri: 'system://status',
- name: 'system_status',
- description: 'Current system status and runtime information',
- mimeType: 'application/json'
- )
- ->build();
-
-$response = $server->run(transport());
-
-shutdown($response);
diff --git a/examples/server/elicitation/ElicitationHandlers.php b/examples/server/elicitation/ElicitationHandlers.php
deleted file mode 100644
index aaa1328a..00000000
--- a/examples/server/elicitation/ElicitationHandlers.php
+++ /dev/null
@@ -1,291 +0,0 @@
-logger->info('ElicitationHandlers instantiated.');
- }
-
- /**
- * Book a restaurant reservation with user elicitation.
- *
- * Demonstrates multi-field elicitation with different field types:
- * - Number field for party size with validation
- * - String field with date format for reservation date
- * - Enum field for dietary restrictions with human-readable labels
- *
- * @return array{status: string, message: string, booking?: array{party_size: int, date: string, dietary: string}}
- */
- #[McpTool(name: 'book_restaurant', description: 'Book a restaurant reservation, collecting details via elicitation.')]
- public function bookRestaurant(RequestContext $context, string $restaurantName): array
- {
- if (!$context->getClientGateway()->supportsElicitation()) {
- return [
- 'status' => 'error',
- 'message' => 'Client does not support elicitation. Please provide reservation details (party_size, date, dietary) as tool parameters instead.',
- ];
- }
-
- $client = $context->getClientGateway();
-
- $this->logger->info(\sprintf('Starting reservation process for restaurant: %s', $restaurantName));
-
- $schema = new ElicitationSchema(
- properties: [
- 'party_size' => new NumberSchemaDefinition(
- title: 'Party Size',
- integerOnly: true,
- description: 'Number of guests in your party',
- default: 2,
- minimum: 1,
- maximum: 20,
- ),
- 'date' => new StringSchemaDefinition(
- title: 'Reservation Date',
- description: 'Preferred date for your reservation',
- format: 'date',
- ),
- 'dietary' => new EnumSchemaDefinition(
- title: 'Dietary Restrictions',
- enum: ['none', 'vegetarian', 'vegan', 'gluten-free', 'halal', 'kosher'],
- description: 'Any dietary restrictions or preferences',
- default: 'none',
- enumNames: ['None', 'Vegetarian', 'Vegan', 'Gluten-Free', 'Halal', 'Kosher'],
- ),
- ],
- required: ['party_size', 'date'],
- );
-
- $result = $client->elicit(
- message: \sprintf('Please provide your reservation details for %s:', $restaurantName),
- requestedSchema: $schema,
- timeout: 120,
- );
-
- if ($result->isDeclined()) {
- $this->logger->info('User declined to provide reservation details.');
-
- return [
- 'status' => 'declined',
- 'message' => 'Reservation request was declined by user.',
- ];
- }
-
- if ($result->isCancelled()) {
- $this->logger->info('User cancelled the reservation request.');
-
- return [
- 'status' => 'cancelled',
- 'message' => 'Reservation request was cancelled.',
- ];
- }
-
- $content = $result->content;
- if (null === $content) {
- throw new \RuntimeException('Expected content for accepted elicitation.');
- }
-
- if (!isset($content['party_size']) || !isset($content['date'])) {
- throw new \RuntimeException('Missing required fields: party_size and date.');
- }
-
- $partySize = (int) $content['party_size'];
- $date = (string) $content['date'];
- $dietary = (string) ($content['dietary'] ?? 'none');
-
- if ($partySize < 1 || $partySize > 20) {
- throw new \RuntimeException(\sprintf('Invalid party size: %d. Must be between 1 and 20.', $partySize));
- }
-
- $this->logger->info(\sprintf(
- 'Booking confirmed: %d guests on %s with %s dietary requirements',
- $partySize,
- $date,
- $dietary,
- ));
-
- return [
- 'status' => 'confirmed',
- 'message' => \sprintf(
- 'Reservation confirmed at %s for %d guests on %s.',
- $restaurantName,
- $partySize,
- $date,
- ),
- 'booking' => [
- 'party_size' => $partySize,
- 'date' => $date,
- 'dietary' => $dietary,
- ],
- ];
- }
-
- /**
- * Confirm an action with a simple boolean elicitation.
- *
- * Demonstrates the simplest elicitation pattern - a yes/no confirmation.
- *
- * @return array{status: string, message: string}
- */
- #[McpTool(name: 'confirm_action', description: 'Request user confirmation before proceeding with an action.')]
- public function confirmAction(RequestContext $context, string $actionDescription): array
- {
- if (!$context->getClientGateway()->supportsElicitation()) {
- return [
- 'status' => 'error',
- 'message' => 'Client does not support elicitation. Please confirm the action explicitly in your request.',
- ];
- }
-
- $client = $context->getClientGateway();
-
- $schema = new ElicitationSchema(
- properties: [
- 'confirm' => new BooleanSchemaDefinition(
- title: 'Confirm',
- description: 'Check to confirm you want to proceed',
- default: false,
- ),
- ],
- required: ['confirm'],
- );
-
- $result = $client->elicit(
- message: \sprintf('Are you sure you want to: %s?', $actionDescription),
- requestedSchema: $schema,
- );
-
- if (!$result->isAccepted()) {
- return [
- 'status' => 'not_confirmed',
- 'message' => 'Action was not confirmed by user.',
- ];
- }
-
- $content = $result->content;
- if (null === $content) {
- throw new \RuntimeException('Expected content for accepted elicitation.');
- }
-
- if (!isset($content['confirm'])) {
- throw new \RuntimeException('Missing required field: confirm.');
- }
-
- $confirmed = (bool) $content['confirm'];
-
- if (!$confirmed) {
- return [
- 'status' => 'not_confirmed',
- 'message' => 'User did not check the confirmation box.',
- ];
- }
-
- $this->logger->info(\sprintf('User confirmed action: %s', $actionDescription));
-
- return [
- 'status' => 'confirmed',
- 'message' => \sprintf('Action confirmed: %s', $actionDescription),
- ];
- }
-
- /**
- * Collect user feedback using elicitation.
- *
- * Demonstrates elicitation with optional fields and enum with labels.
- *
- * @return array{status: string, message: string, feedback?: array{rating: string, comments: string}}
- */
- #[McpTool(name: 'collect_feedback', description: 'Collect user feedback via elicitation form.')]
- public function collectFeedback(RequestContext $context, string $topic): array
- {
- if (!$context->getClientGateway()->supportsElicitation()) {
- return [
- 'status' => 'error',
- 'message' => 'Client does not support elicitation. Please provide feedback (rating 1-5, comments) as tool parameters instead.',
- ];
- }
-
- $client = $context->getClientGateway();
-
- $schema = new ElicitationSchema(
- properties: [
- 'rating' => new EnumSchemaDefinition(
- title: 'Rating',
- enum: ['1', '2', '3', '4', '5'],
- description: 'Rate your experience from 1 (poor) to 5 (excellent)',
- enumNames: ['1 - Poor', '2 - Fair', '3 - Good', '4 - Very Good', '5 - Excellent'],
- ),
- 'comments' => new StringSchemaDefinition(
- title: 'Comments',
- description: 'Any additional comments or suggestions (optional)',
- maxLength: 500,
- ),
- ],
- required: ['rating'],
- );
-
- $result = $client->elicit(
- message: \sprintf('Please provide your feedback about: %s', $topic),
- requestedSchema: $schema,
- );
-
- if (!$result->isAccepted()) {
- return [
- 'status' => 'skipped',
- 'message' => 'User chose not to provide feedback.',
- ];
- }
-
- $content = $result->content;
- if (null === $content) {
- throw new \RuntimeException('Expected content for accepted elicitation.');
- }
-
- if (!isset($content['rating'])) {
- throw new \RuntimeException('Missing required field: rating.');
- }
-
- $rating = (string) $content['rating'];
- $comments = (string) ($content['comments'] ?? '');
-
- $this->logger->info(\sprintf('Feedback received: rating=%s, comments=%s', $rating, $comments));
-
- return [
- 'status' => 'received',
- 'message' => 'Thank you for your feedback!',
- 'feedback' => [
- 'rating' => $rating,
- 'comments' => $comments,
- ],
- ];
- }
-}
diff --git a/examples/server/elicitation/server.php b/examples/server/elicitation/server.php
deleted file mode 100644
index 28a9b917..00000000
--- a/examples/server/elicitation/server.php
+++ /dev/null
@@ -1,38 +0,0 @@
-#!/usr/bin/env php
-setServerInfo('Elicitation Demo', '1.0.0')
- ->setLogger(logger())
- ->setContainer(container())
- ->setSession(new FileSessionStore(__DIR__.'/sessions'))
- ->setCapabilities(new ServerCapabilities(logging: true, tools: true))
- ->setDiscovery(__DIR__)
- ->build();
-
-$result = $server->run(transport());
-
-shutdown($result);
diff --git a/examples/server/env-variables/EnvToolHandler.php b/examples/server/env-variables/EnvToolHandler.php
deleted file mode 100644
index 0520a715..00000000
--- a/examples/server/env-variables/EnvToolHandler.php
+++ /dev/null
@@ -1,75 +0,0 @@
- the result, varying by APP_MODE
- */
- #[McpTool(
- name: 'process_data_by_mode',
- outputSchema: [
- 'type' => 'object',
- 'properties' => [
- 'mode' => [
- 'type' => 'string',
- 'description' => 'The processing mode used',
- ],
- 'processed_input' => [
- 'type' => 'string',
- 'description' => 'The processed input data',
- ],
- 'original_input' => [
- 'type' => 'string',
- 'description' => 'The original input data (only in default mode)',
- ],
- 'message' => [
- 'type' => 'string',
- 'description' => 'A descriptive message about the processing',
- ],
- ],
- 'required' => ['mode', 'message'],
- ]
- )]
- public function processData(string $input): array
- {
- $appMode = getenv('APP_MODE'); // Read from environment
-
- if ('debug' === $appMode) {
- return [
- 'mode' => 'debug',
- 'processed_input' => strtoupper($input),
- 'message' => 'Processed in DEBUG mode.',
- ];
- } elseif ('production' === $appMode) {
- return [
- 'mode' => 'production',
- 'processed_input_length' => \strlen($input),
- 'message' => 'Processed in PRODUCTION mode (summary only).',
- ];
- }
-
- return [
- 'mode' => $appMode ?: 'default',
- 'original_input' => $input,
- 'message' => 'Processed in default mode (APP_MODE not recognized or not set).',
- ];
- }
-}
diff --git a/examples/server/env-variables/server.php b/examples/server/env-variables/server.php
deleted file mode 100644
index 48c43825..00000000
--- a/examples/server/env-variables/server.php
+++ /dev/null
@@ -1,61 +0,0 @@
-#!/usr/bin/env php
-info('Starting MCP Environment Variable Example Server...');
-
-$server = Server::builder()
- ->setServerInfo('Env Var Server', '1.0.0')
- ->setLogger(logger())
- ->setDiscovery(__DIR__)
- ->build();
-
-$result = $server->run(transport());
-
-logger()->info('Server listener stopped gracefully.', ['result' => $result]);
-
-shutdown($result);
diff --git a/examples/server/explicit-registration/SimpleHandlers.php b/examples/server/explicit-registration/SimpleHandlers.php
deleted file mode 100644
index 0f18a33c..00000000
--- a/examples/server/explicit-registration/SimpleHandlers.php
+++ /dev/null
@@ -1,81 +0,0 @@
-logger->info('SimpleHandlers instantiated for manual registration example.');
- }
-
- /**
- * A manually registered tool to echo input.
- *
- * @param string $text the text to echo
- *
- * @return string the echoed text
- */
- public function echoText(string $text): string
- {
- $this->logger->info("Manual tool 'echo_text' called.", ['text' => $text]);
-
- return 'Echo: '.$text;
- }
-
- /**
- * A manually registered resource providing app version.
- *
- * @return string the application version
- */
- public function getAppVersion(): string
- {
- $this->logger->info("Manual resource 'app://version' read.");
-
- return $this->appVersion;
- }
-
- /**
- * A manually registered prompt template.
- *
- * @param string $userName the name of the user
- *
- * @return array[] the prompt messages
- */
- public function greetingPrompt(string $userName): array
- {
- $this->logger->info("Manual prompt 'personalized_greeting' called.", ['userName' => $userName]);
-
- return [
- ['role' => 'user', 'content' => "Craft a personalized greeting for {$userName}."],
- ];
- }
-
- /**
- * A manually registered resource template.
- *
- * @param string $itemId the ID of the item
- *
- * @return array item details
- */
- public function getItemDetails(string $itemId): array
- {
- $this->logger->info("Manual template 'item://{itemId}' resolved.", ['itemId' => $itemId]);
-
- return ['id' => $itemId, 'name' => "Item {$itemId}", 'description' => "Details for item {$itemId} from manual template."];
- }
-}
diff --git a/examples/server/explicit-registration/server.php b/examples/server/explicit-registration/server.php
deleted file mode 100644
index 977ee439..00000000
--- a/examples/server/explicit-registration/server.php
+++ /dev/null
@@ -1,47 +0,0 @@
-#!/usr/bin/env php
-info('Starting MCP Manual Registration Server...');
-
-$server = Server::builder()
- ->setServerInfo('Explicit Registration Server', '1.0.0')
- ->setLogger(logger())
- ->setContainer(container())
- ->addTool([SimpleHandlers::class, 'echoText'], 'echo_text')
- ->addResource([SimpleHandlers::class, 'getAppVersion'], 'app://version', 'application_version', mimeType: 'text/plain')
- ->addPrompt([SimpleHandlers::class, 'greetingPrompt'], 'personalized_greeting')
- ->addResourceTemplate([SimpleHandlers::class, 'getItemDetails'], 'item://{itemId}/details', 'get_item_details', mimeType: 'application/json')
- ->setCapabilities(new ServerCapabilities(
- tools: true,
- toolsListChanged: false,
- resources: true,
- resourcesSubscribe: false,
- resourcesListChanged: false,
- prompts: true,
- promptsListChanged: false,
- logging: false,
- completions: false,
- ))
- ->build();
-
-$result = $server->run(transport());
-
-logger()->info('Server listener stopped gracefully.', ['result' => $result]);
-
-shutdown($result);
diff --git a/examples/server/mcp-apps/WeatherApp.php b/examples/server/mcp-apps/WeatherApp.php
deleted file mode 100644
index 5facead6..00000000
--- a/examples/server/mcp-apps/WeatherApp.php
+++ /dev/null
@@ -1,67 +0,0 @@
- $contentMeta],
- );
- }
-
- public function getWeather(string $city): string
- {
- $weather = [
- 'london' => ['temp' => '15°C', 'condition' => 'Cloudy', 'humidity' => '78%'],
- 'paris' => ['temp' => '18°C', 'condition' => 'Sunny', 'humidity' => '55%'],
- 'tokyo' => ['temp' => '22°C', 'condition' => 'Partly Cloudy', 'humidity' => '65%'],
- 'new york' => ['temp' => '12°C', 'condition' => 'Rainy', 'humidity' => '85%'],
- 'lagos' => ['temp' => '30°C', 'condition' => 'Sunny', 'humidity' => '82%'],
- 'stockholm' => ['temp' => '4°C', 'condition' => 'Cloudy', 'humidity' => '70%'],
- 'berlin' => ['temp' => '9°C', 'condition' => 'Partly Cloudy', 'humidity' => '68%'],
- 'sydney' => ['temp' => '26°C', 'condition' => 'Sunny', 'humidity' => '60%'],
- 'buenos aires' => ['temp' => '24°C', 'condition' => 'Rainy', 'humidity' => '80%'],
- ];
-
- $key = strtolower($city);
- $data = $weather[$key] ?? ['temp' => '20°C', 'condition' => 'Clear', 'humidity' => '60%'];
-
- return \sprintf(
- 'Weather in %s: %s, %s, Humidity: %s',
- $city,
- $data['temp'],
- $data['condition'],
- $data['humidity'],
- );
- }
-}
diff --git a/examples/server/mcp-apps/server.php b/examples/server/mcp-apps/server.php
deleted file mode 100644
index d8ca2592..00000000
--- a/examples/server/mcp-apps/server.php
+++ /dev/null
@@ -1,51 +0,0 @@
-#!/usr/bin/env php
-info('Starting MCP Apps Example Server...');
-
-$server = Server::builder()
- ->setServerInfo('MCP Apps Weather Example', '1.0.0')
- ->setLogger(logger())
- ->enableExtension(new McpApps())
- ->addResource(
- [WeatherApp::class, 'getWeatherApp'],
- 'ui://weather-app',
- 'weather-app',
- description: 'Interactive weather dashboard',
- mimeType: McpApps::MIME_TYPE,
- meta: ['ui' => McpApps::resourceMarker()],
- )
- ->addTool(
- [WeatherApp::class, 'getWeather'],
- 'get_weather',
- description: 'Get current weather for a city',
- meta: ['ui' => new UiToolMeta(
- resourceUri: 'ui://weather-app',
- visibility: [ToolVisibility::Model, ToolVisibility::App],
- )],
- )
- ->build();
-
-$result = $server->run(transport());
-
-logger()->info('Server stopped gracefully.', ['result' => $result]);
-
-shutdown($result);
diff --git a/examples/server/mcp-apps/weather-app.html b/examples/server/mcp-apps/weather-app.html
deleted file mode 100644
index 1997a2c6..00000000
--- a/examples/server/mcp-apps/weather-app.html
+++ /dev/null
@@ -1,230 +0,0 @@
-
-
-
-
-
- Weather Dashboard
-
-
-
-
-
- Get Weather
-
-
-
-
-
diff --git a/examples/server/oauth-keycloak/Dockerfile b/examples/server/oauth-keycloak/Dockerfile
deleted file mode 100644
index f877c73a..00000000
--- a/examples/server/oauth-keycloak/Dockerfile
+++ /dev/null
@@ -1,23 +0,0 @@
-FROM php:8.1-fpm-alpine
-
-# Install dependencies
-RUN apk add --no-cache \
- curl \
- git \
- unzip
-
-# Install Composer
-COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
-
-# Set working directory
-WORKDIR /app
-
-# Install PHP extensions
-RUN docker-php-ext-install opcache
-
-# Configure PHP-FPM to listen on TCP
-RUN sed -i 's/listen = .*/listen = 9000/' /usr/local/etc/php-fpm.d/www.conf
-
-EXPOSE 9000
-
-CMD ["php-fpm"]
diff --git a/examples/server/oauth-keycloak/McpElements.php b/examples/server/oauth-keycloak/McpElements.php
deleted file mode 100644
index b7234c7f..00000000
--- a/examples/server/oauth-keycloak/McpElements.php
+++ /dev/null
@@ -1,132 +0,0 @@
-
- */
- #[McpTool(
- name: 'get_auth_status',
- description: 'Confirm authentication status - only accessible with valid OAuth token'
- )]
- public function getAuthStatus(RequestContext $context): array
- {
- $meta = $context->getRequest()->getMeta() ?? [];
- $oauth = isset($meta['oauth']) && \is_array($meta['oauth']) ? $meta['oauth'] : [];
- $claims = isset($oauth['oauth.claims']) && \is_array($oauth['oauth.claims']) ? $oauth['oauth.claims'] : [];
- $scopes = isset($oauth['oauth.scopes']) && \is_array($oauth['oauth.scopes']) ? $oauth['oauth.scopes'] : [];
-
- return [
- 'authenticated' => true,
- 'provider' => 'Keycloak',
- 'message' => 'You have successfully authenticated with OAuth!',
- 'timestamp' => date('c'),
- 'user' => [
- 'subject' => $oauth['oauth.subject'] ?? ($claims['sub'] ?? null),
- 'username' => $claims['preferred_username'] ?? null,
- 'name' => $claims['name'] ?? null,
- 'email' => $claims['email'] ?? null,
- 'issuer' => $claims['iss'] ?? null,
- 'audience' => $claims['aud'] ?? null,
- 'scopes' => $scopes,
- 'expires_at' => isset($claims['exp']) && is_numeric($claims['exp'])
- ? date('c', (int) $claims['exp'])
- : null,
- ],
- 'note' => 'This endpoint is protected by JWT validation. If you see this, your token was valid.',
- ];
- }
-
- /**
- * Simulates calling a protected external API.
- *
- * @return array
- */
- #[McpTool(
- name: 'call_protected_api',
- description: 'Simulate calling a protected external API endpoint'
- )]
- public function callProtectedApi(
- string $endpoint,
- string $method = 'GET',
- ): array {
- // In a real implementation, you would:
- // 1. Use token exchange to get a token for the downstream API
- // 2. Or use client credentials with the user's context
- // 3. Make the actual HTTP call to the protected API
-
- return [
- 'status' => 'success',
- 'message' => \sprintf('Simulated %s request to %s', $method, $endpoint),
- 'simulated_response' => [
- 'data' => 'This is simulated data from the protected API',
- 'timestamp' => date('c'),
- ],
- ];
- }
-
- /**
- * Returns the current server time and status.
- *
- * @return array
- */
- #[McpResource(
- uri: 'server://status',
- name: 'server_status',
- description: 'Current server status (protected resource)',
- mimeType: 'application/json'
- )]
- public function getServerStatus(): array
- {
- return [
- 'status' => 'healthy',
- 'timestamp' => date('c'),
- 'php_version' => \PHP_VERSION,
- 'memory_usage_mb' => round(memory_get_usage(true) / 1024 / 1024, 2),
- 'protected' => true,
- ];
- }
-
- /**
- * A greeting prompt.
- */
- #[McpPrompt(
- name: 'greeting',
- description: 'Generate a greeting message'
- )]
- public function greeting(string $style = 'formal'): string
- {
- return match ($style) {
- 'casual' => 'Hey there! Welcome to the protected MCP server!',
- 'formal' => 'Good day. Welcome to the OAuth-protected MCP server.',
- 'friendly' => 'Hello! Great to have you here!',
- default => 'Welcome to the MCP server!',
- };
- }
-}
diff --git a/examples/server/oauth-keycloak/README.md b/examples/server/oauth-keycloak/README.md
deleted file mode 100644
index f9f46de7..00000000
--- a/examples/server/oauth-keycloak/README.md
+++ /dev/null
@@ -1,135 +0,0 @@
-# OAuth Keycloak Example
-
-This example demonstrates MCP server authorization using Keycloak as the OAuth 2.0 / OpenID Connect provider.
-
-## Features
-
-- JWT token validation with automatic JWKS discovery
-- Protected Resource Metadata (RFC 9728) at `/.well-known/oauth-protected-resource`
-- MCP tools protected by OAuth authentication
-- Pre-configured Keycloak realm with test user
-
-## Quick Start
-
-1. **Start the services:**
-
-```bash
-docker compose up -d
-```
-
-2. **Wait for Keycloak to be ready** (may take 30-60 seconds):
-
-```bash
-docker compose logs -f keycloak
-# Wait until you see "Running the server in development mode"
-```
-
-3. **Get an access token:**
-
-```bash
-# Using Resource Owner Password Credentials (for testing only)
-TOKEN=$(curl -s -X POST "http://localhost:8180/realms/mcp/protocol/openid-connect/token" \
- -H "Content-Type: application/x-www-form-urlencoded" \
- -d "client_id=mcp-client" \
- -d "username=demo" \
- -d "password=demo123" \
- -d "grant_type=password" \
- -d "scope=openid mcp" | jq -r '.access_token')
-
-echo $TOKEN
-```
-
-4. **Test the MCP server:**
-
-```bash
-# Get Protected Resource Metadata
-curl http://localhost:8000/.well-known/oauth-protected-resource
-
-# Call MCP endpoint without token (should get 401)
-curl -i http://localhost:8000/mcp
-
-# Call MCP endpoint with token
-curl -X POST http://localhost:8000/mcp \
- -H "Authorization: Bearer $TOKEN" \
- -H "Content-Type: application/json" \
- -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}'
-```
-
-5. **Use with MCP Inspector:**
-
-MCP Inspector can call this server if you provide a valid Bearer token manually (Authorization header). It does not run the OAuth login flow automatically.
-
-## Keycloak Configuration
-
-The realm is pre-configured with:
-
-| Item | Value |
-|------|-------|
-| Realm | `mcp` |
-| Client (public) | `mcp-client` |
-| Client (resource) | `mcp-server` |
-| Test User | `demo` / `demo123` |
-| Scopes | `mcp:read`, `mcp:write` |
-
-### Keycloak Admin Console
-
-Access at http://localhost:8180/admin with:
-- Username: `admin`
-- Password: `admin`
-
-## Architecture
-
-```
-┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
-│ MCP Client │────▶│ Nginx │────▶│ PHP-FPM │
-│ │ │ (port 8000) │ │ MCP Server │
-└─────────────────┘ └─────────────────┘ └─────────────────┘
- │ │
- │ Get Token │ Validate JWT
- ▼ ▼
-┌─────────────────┐ ┌─────────────────┐
-│ Keycloak │◀───────────────────────────│ JWKS Fetch │
-│ (port 8180) │ │ │
-└─────────────────┘ └─────────────────┘
-```
-
-## Files
-
-- `docker-compose.yml` - Docker Compose configuration
-- `Dockerfile` - PHP-FPM container with dependencies
-- `nginx/default.conf` - Nginx configuration for MCP endpoint
-- `keycloak/mcp-realm.json` - Pre-configured Keycloak realm
-- `server.php` - MCP server with OAuth middleware
-- `McpElements.php` - MCP tools and resources
-
-## Configuration
-
-This example uses hard-coded values in `server.php` for consistency with other examples:
-- Keycloak external URL: `http://localhost:8180`
-- Keycloak internal URL: `http://keycloak:8180`
-- Realm: `mcp`
-- Audience: `mcp-server`
-
-## Troubleshooting
-
-### Token validation fails
-
-1. Ensure Keycloak is fully started (check health endpoint)
-2. Verify the token hasn't expired (default: 5 minutes)
-3. Check that the audience claim matches `mcp-server`
-
-### Connection refused
-
-1. Wait for Keycloak health check to pass
-2. Check Docker network connectivity: `docker compose logs`
-
-### JWKS fetch fails
-
-The MCP server needs to reach Keycloak at `http://keycloak:8180` (Docker network).
-For local development outside Docker, use `http://localhost:8180`.
-
-## Cleanup
-
-```bash
-docker compose down -v
-```
diff --git a/examples/server/oauth-keycloak/docker-compose.yml b/examples/server/oauth-keycloak/docker-compose.yml
deleted file mode 100644
index 21562445..00000000
--- a/examples/server/oauth-keycloak/docker-compose.yml
+++ /dev/null
@@ -1,69 +0,0 @@
-services:
- keycloak:
- image: quay.io/keycloak/keycloak:24.0
- container_name: mcp-keycloak
- environment:
- KEYCLOAK_ADMIN: admin
- KEYCLOAK_ADMIN_PASSWORD: admin
- KC_HEALTH_ENABLED: "true"
- volumes:
- - ./keycloak/mcp-realm.json:/opt/keycloak/data/import/mcp-realm.json:ro
- command:
- - start-dev
- - --import-realm
- - --http-port=8180
- ports:
- - "8180:8180"
- healthcheck:
- test: ["CMD-SHELL", "exec 3<>/dev/tcp/127.0.0.1/8180;echo -e 'GET /health/ready HTTP/1.1\r\nhost: localhost\r\nConnection: close\r\n\r\n' >&3;if [ $? -eq 0 ]; then echo 'Healthcheck Successful';exit 0;else echo 'Healthcheck Failed';exit 1;fi;"]
- interval: 10s
- timeout: 5s
- retries: 15
- start_period: 30s
- networks:
- - mcp-network
-
- php:
- build:
- context: .
- dockerfile: Dockerfile
- container_name: mcp-php
- volumes:
- - ../../../:/app
- working_dir: /app
- environment:
- KEYCLOAK_EXTERNAL_URL: http://localhost:8180
- KEYCLOAK_INTERNAL_URL: http://keycloak:8180
- KEYCLOAK_REALM: mcp
- MCP_AUDIENCE: mcp-server
- depends_on:
- keycloak:
- condition: service_healthy
- command: >
- sh -c "mkdir -p /app/examples/server/oauth-keycloak/sessions;
- chmod -R 0777 /app/examples/server/oauth-keycloak/sessions;
- touch /app/examples/server/oauth-keycloak/dev.log;
- chmod 0666 /app/examples/server/oauth-keycloak/dev.log;
- touch /app/examples/server/dev.log;
- chmod 0666 /app/examples/server/dev.log;
- composer install --no-interaction --quiet 2>/dev/null || true;
- php-fpm"
- networks:
- - mcp-network
-
- nginx:
- image: nginx:alpine
- container_name: mcp-nginx
- ports:
- - "8000:80"
- volumes:
- - ./nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
- - ../../../:/app:ro
- depends_on:
- - php
- networks:
- - mcp-network
-
-networks:
- mcp-network:
- driver: bridge
diff --git a/examples/server/oauth-keycloak/keycloak/mcp-realm.json b/examples/server/oauth-keycloak/keycloak/mcp-realm.json
deleted file mode 100644
index 55d28751..00000000
--- a/examples/server/oauth-keycloak/keycloak/mcp-realm.json
+++ /dev/null
@@ -1,128 +0,0 @@
-{
- "realm": "mcp",
- "enabled": true,
- "registrationAllowed": false,
- "loginWithEmailAllowed": true,
- "duplicateEmailsAllowed": false,
- "resetPasswordAllowed": true,
- "editUsernameAllowed": false,
- "bruteForceProtected": true,
- "accessTokenLifespan": 300,
- "ssoSessionIdleTimeout": 1800,
- "ssoSessionMaxLifespan": 36000,
- "clients": [
- {
- "clientId": "mcp-client",
- "name": "MCP Client Application",
- "description": "Public client for MCP client applications",
- "enabled": true,
- "publicClient": true,
- "standardFlowEnabled": true,
- "directAccessGrantsEnabled": true,
- "serviceAccountsEnabled": false,
- "authorizationServicesEnabled": false,
- "fullScopeAllowed": true,
- "redirectUris": [
- "http://localhost:*",
- "http://127.0.0.1:*"
- ],
- "webOrigins": [
- "http://localhost:*",
- "http://127.0.0.1:*"
- ],
- "defaultClientScopes": [
- "openid",
- "profile",
- "email",
- "mcp"
- ],
- "optionalClientScopes": [],
- "attributes": {
- "pkce.code.challenge.method": "S256"
- }
- },
- {
- "clientId": "mcp-server",
- "name": "MCP Server Resource",
- "description": "Resource server representing the MCP server",
- "enabled": true,
- "publicClient": false,
- "bearerOnly": true,
- "standardFlowEnabled": false,
- "directAccessGrantsEnabled": false,
- "serviceAccountsEnabled": false,
- "authorizationServicesEnabled": false
- }
- ],
- "clientScopes": [
- {
- "name": "mcp",
- "description": "MCP access scope",
- "protocol": "openid-connect",
- "attributes": {
- "include.in.token.scope": "true",
- "display.on.consent.screen": "true",
- "consent.screen.text": "Access to MCP server resources"
- },
- "protocolMappers": [
- {
- "name": "mcp-audience",
- "protocol": "openid-connect",
- "protocolMapper": "oidc-audience-mapper",
- "consentRequired": false,
- "config": {
- "included.client.audience": "mcp-server",
- "id.token.claim": "false",
- "access.token.claim": "true"
- }
- },
- {
- "name": "mcp-scopes",
- "protocol": "openid-connect",
- "protocolMapper": "oidc-hardcoded-claim-mapper",
- "consentRequired": false,
- "config": {
- "claim.name": "scope",
- "claim.value": "mcp:read mcp:write",
- "jsonType.label": "String",
- "id.token.claim": "false",
- "access.token.claim": "true",
- "userinfo.token.claim": "false"
- }
- }
- ]
- }
- ],
- "users": [
- {
- "username": "demo",
- "email": "demo@example.com",
- "emailVerified": true,
- "enabled": true,
- "firstName": "Demo",
- "lastName": "User",
- "credentials": [
- {
- "type": "password",
- "value": "demo123",
- "temporary": false
- }
- ],
- "realmRoles": ["default-roles-mcp"]
- }
- ],
- "defaultDefaultClientScopes": [
- "openid",
- "profile",
- "email"
- ],
- "roles": {
- "realm": [
- {
- "name": "default-roles-mcp",
- "description": "Default roles for MCP realm",
- "composite": false
- }
- ]
- }
-}
diff --git a/examples/server/oauth-keycloak/nginx/default.conf b/examples/server/oauth-keycloak/nginx/default.conf
deleted file mode 100644
index f7a265ad..00000000
--- a/examples/server/oauth-keycloak/nginx/default.conf
+++ /dev/null
@@ -1,25 +0,0 @@
-server {
- listen 80;
- server_name localhost;
- root /app/examples/server/oauth-keycloak;
-
- # Route all requests through PHP
- location / {
- try_files $uri /server.php$is_args$args;
- }
-
- # PHP processing
- location ~ \.php$ {
- fastcgi_pass php:9000;
- fastcgi_index server.php;
- fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
- include fastcgi_params;
-
- # Pass all request info
- fastcgi_param REQUEST_URI $request_uri;
- fastcgi_param QUERY_STRING $query_string;
- fastcgi_param REQUEST_METHOD $request_method;
- fastcgi_param CONTENT_TYPE $content_type;
- fastcgi_param CONTENT_LENGTH $content_length;
- }
-}
diff --git a/examples/server/oauth-keycloak/server.php b/examples/server/oauth-keycloak/server.php
deleted file mode 100644
index fdaae7a0..00000000
--- a/examples/server/oauth-keycloak/server.php
+++ /dev/null
@@ -1,71 +0,0 @@
-setServerInfo('OAuth Keycloak Example', '1.0.0')
- ->setLogger(logger())
- ->setSession(new FileSessionStore(__DIR__.'/sessions'))
- ->setDiscovery(__DIR__)
- ->build();
-
-$transport = new StreamableHttpTransport(
- (new Psr17Factory())->createServerRequestFromGlobals(),
- logger: logger(),
- middleware: [
- ...StreamableHttpTransport::defaultMiddleware(),
- $metadataMiddleware,
- $authMiddleware,
- new OAuthRequestMetaMiddleware(),
- ],
-);
-
-$response = $server->run($transport);
-
-(new SapiEmitter())->emit($response);
diff --git a/examples/server/oauth-microsoft/.env.dist b/examples/server/oauth-microsoft/.env.dist
deleted file mode 100644
index de4376e9..00000000
--- a/examples/server/oauth-microsoft/.env.dist
+++ /dev/null
@@ -1,3 +0,0 @@
-AZURE_TENANT_ID=
-AZURE_CLIENT_ID=
-AZURE_CLIENT_SECRET=
\ No newline at end of file
diff --git a/examples/server/oauth-microsoft/Dockerfile b/examples/server/oauth-microsoft/Dockerfile
deleted file mode 100644
index f877c73a..00000000
--- a/examples/server/oauth-microsoft/Dockerfile
+++ /dev/null
@@ -1,23 +0,0 @@
-FROM php:8.1-fpm-alpine
-
-# Install dependencies
-RUN apk add --no-cache \
- curl \
- git \
- unzip
-
-# Install Composer
-COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
-
-# Set working directory
-WORKDIR /app
-
-# Install PHP extensions
-RUN docker-php-ext-install opcache
-
-# Configure PHP-FPM to listen on TCP
-RUN sed -i 's/listen = .*/listen = 9000/' /usr/local/etc/php-fpm.d/www.conf
-
-EXPOSE 9000
-
-CMD ["php-fpm"]
diff --git a/examples/server/oauth-microsoft/McpElements.php b/examples/server/oauth-microsoft/McpElements.php
deleted file mode 100644
index 48208f22..00000000
--- a/examples/server/oauth-microsoft/McpElements.php
+++ /dev/null
@@ -1,152 +0,0 @@
-
- */
- #[McpTool(
- name: 'get_auth_status',
- description: 'Confirm Microsoft Entra ID authentication status'
- )]
- public function getAuthStatus(RequestContext $context): array
- {
- $meta = $context->getRequest()->getMeta() ?? [];
- $oauth = isset($meta['oauth']) && \is_array($meta['oauth']) ? $meta['oauth'] : [];
- $claims = isset($oauth['oauth.claims']) && \is_array($oauth['oauth.claims']) ? $oauth['oauth.claims'] : [];
- $scopes = isset($oauth['oauth.scopes']) && \is_array($oauth['oauth.scopes']) ? $oauth['oauth.scopes'] : [];
-
- return [
- 'authenticated' => true,
- 'provider' => 'Microsoft Entra ID',
- 'message' => 'You have successfully authenticated with Microsoft!',
- 'timestamp' => date('c'),
- 'user' => [
- 'subject' => $oauth['oauth.subject'] ?? ($claims['sub'] ?? null),
- 'object_id' => $oauth['oauth.object_id'] ?? ($claims['oid'] ?? null),
- 'username' => $claims['preferred_username'] ?? ($claims['upn'] ?? null),
- 'name' => $oauth['oauth.name'] ?? ($claims['name'] ?? null),
- 'email' => $claims['email'] ?? null,
- 'issuer' => $claims['iss'] ?? null,
- 'audience' => $claims['aud'] ?? null,
- 'tenant_id' => $claims['tid'] ?? null,
- 'scopes' => $scopes,
- 'expires_at' => isset($claims['exp']) && is_numeric($claims['exp'])
- ? date('c', (int) $claims['exp'])
- : null,
- ],
- ];
- }
-
- /**
- * Simulates calling Microsoft Graph API.
- *
- * @return array
- */
- #[McpTool(
- name: 'call_graph_api',
- description: 'Simulate calling Microsoft Graph API'
- )]
- public function callGraphApi(
- string $endpoint = '/me',
- ): array {
- // In a real implementation, you would:
- // 1. Use the On-Behalf-Of flow to exchange tokens
- // 2. Call Microsoft Graph with the new token
-
- return [
- 'status' => 'simulated',
- 'endpoint' => "https://graph.microsoft.com/v1.0{$endpoint}",
- 'message' => 'Configure AZURE_CLIENT_SECRET for actual Graph API calls',
- 'simulated_response' => [
- 'displayName' => 'Demo User',
- 'mail' => 'demo@example.com',
- ],
- ];
- }
-
- /**
- * Lists simulated emails.
- *
- * @return array
- */
- #[McpTool(
- name: 'list_emails',
- description: 'List recent emails (simulated)'
- )]
- public function listEmails(int $count = 5): array
- {
- return [
- 'note' => 'Simulated data. Implement Graph API call with Mail.Read scope for real emails.',
- 'emails' => array_map(static fn ($i) => [
- 'id' => 'msg_'.uniqid(),
- 'subject' => "Sample Email #{$i}",
- 'from' => "sender{$i}@example.com",
- 'receivedDateTime' => date('c', strtotime("-{$i} hours")),
- ], range(1, $count)),
- ];
- }
-
- /**
- * Returns the current server status.
- *
- * @return array
- */
- #[McpResource(
- uri: 'server://status',
- name: 'server_status',
- description: 'Current server status with Microsoft auth info',
- mimeType: 'application/json'
- )]
- public function getServerStatus(): array
- {
- return [
- 'status' => 'healthy',
- 'timestamp' => date('c'),
- 'auth_provider' => 'Microsoft Entra ID',
- 'php_version' => \PHP_VERSION,
- 'memory_usage_mb' => round(memory_get_usage(true) / 1024 / 1024, 2),
- ];
- }
-
- /**
- * A Microsoft Teams-style message prompt.
- */
- #[McpPrompt(
- name: 'teams_message',
- description: 'Generate a Microsoft Teams-style message'
- )]
- public function teamsMessage(string $messageType = 'announcement'): string
- {
- return match ($messageType) {
- 'announcement' => "📢 **Announcement**\n\nPlease add your announcement content here.",
- 'question' => "❓ **Question**\n\nType your question here.",
- 'update' => "📋 **Status Update**\n\n**Progress:**\n- Item 1\n- Item 2",
- default => "💬 **Message**\n\nYour message content here.",
- };
- }
-}
diff --git a/examples/server/oauth-microsoft/MicrosoftJwtTokenValidator.php b/examples/server/oauth-microsoft/MicrosoftJwtTokenValidator.php
deleted file mode 100644
index f389430c..00000000
--- a/examples/server/oauth-microsoft/MicrosoftJwtTokenValidator.php
+++ /dev/null
@@ -1,187 +0,0 @@
-
- */
-class MicrosoftJwtTokenValidator implements AuthorizationTokenValidatorInterface
-{
- /**
- * @param JwtTokenValidator $jwtTokenValidator Base JWT validator used for non-Graph tokens
- * @param string $scopeClaim Claim name for scopes in Graph tokens
- * @param list $trustedGraphIssuers Allowed Graph issuer host markers
- * @param int $notBeforeLeewaySeconds Allowed clock skew for "nbf" claim
- */
- public function __construct(
- private readonly JwtTokenValidator $jwtTokenValidator,
- private readonly string $scopeClaim = 'scp',
- private readonly array $trustedGraphIssuers = ['sts.windows.net', 'login.microsoftonline.com'],
- private readonly int $notBeforeLeewaySeconds = 60,
- ) {
- }
-
- public function validate(string $accessToken): AuthorizationResult
- {
- $parts = explode('.', $accessToken);
- if (!$this->isGraphToken($parts)) {
- return $this->jwtTokenValidator->validate($accessToken);
- }
-
- return $this->validateGraphToken($parts);
- }
-
- /**
- * Validates a token has the required scopes.
- *
- * Use this after validation to check specific scope requirements.
- *
- * @param AuthorizationResult $result The result from validate()
- * @param list $requiredScopes Scopes required for this operation
- *
- * @return AuthorizationResult The original result if scopes are sufficient, forbidden otherwise
- */
- public function requireScopes(AuthorizationResult $result, array $requiredScopes): AuthorizationResult
- {
- return $this->jwtTokenValidator->requireScopes($result, $requiredScopes);
- }
-
- /**
- * @param array $parts
- */
- private function isGraphToken(array $parts): bool
- {
- if ([] === $parts) {
- return false;
- }
-
- $header = $this->decodePartToArray($parts[0]);
- if (null === $header) {
- return false;
- }
-
- return isset($header['nonce']);
- }
-
- /**
- * @param array $parts
- */
- private function validateGraphToken(array $parts): AuthorizationResult
- {
- // Intentionally claim-based only for example Graph token compatibility.
- if (\count($parts) < 2) {
- return AuthorizationResult::unauthorized('invalid_token', 'Invalid token format.');
- }
-
- $payload = $this->decodePartToArray($parts[1]);
- if (null === $payload) {
- return AuthorizationResult::unauthorized('invalid_token', 'Invalid token payload.');
- }
-
- if (isset($payload['exp']) && is_numeric($payload['exp']) && (int) $payload['exp'] < time()) {
- return AuthorizationResult::unauthorized('invalid_token', 'Token has expired.');
- }
-
- if (isset($payload['nbf']) && is_numeric($payload['nbf']) && (int) $payload['nbf'] > time() + $this->notBeforeLeewaySeconds) {
- return AuthorizationResult::unauthorized('invalid_token', 'Token is not yet valid.');
- }
-
- $issuer = $payload['iss'] ?? '';
- if (!\is_string($issuer) || !$this->isTrustedGraphIssuer($issuer)) {
- return AuthorizationResult::unauthorized('invalid_token', 'Invalid token issuer for Graph token.');
- }
-
- $scopes = $this->extractScopes($payload);
-
- $attributes = [
- 'oauth.claims' => $payload,
- 'oauth.scopes' => $scopes,
- 'oauth.graph_token' => true,
- ];
-
- if (isset($payload['sub'])) {
- $attributes['oauth.subject'] = $payload['sub'];
- }
-
- if (isset($payload['oid'])) {
- $attributes['oauth.object_id'] = $payload['oid'];
- }
-
- if (isset($payload['name'])) {
- $attributes['oauth.name'] = $payload['name'];
- }
-
- return AuthorizationResult::allow($attributes);
- }
-
- private function isTrustedGraphIssuer(string $issuer): bool
- {
- $host = parse_url($issuer, \PHP_URL_HOST);
-
- return \in_array($host, $this->trustedGraphIssuers, true);
- }
-
- /**
- * @param array $claims
- *
- * @return list
- */
- private function extractScopes(array $claims): array
- {
- if (!isset($claims[$this->scopeClaim])) {
- return [];
- }
-
- $scopeValue = $claims[$this->scopeClaim];
-
- if (\is_array($scopeValue)) {
- return array_values(array_filter($scopeValue, 'is_string'));
- }
-
- if (\is_string($scopeValue)) {
- return array_values(array_filter(explode(' ', $scopeValue)));
- }
-
- return [];
- }
-
- /**
- * @return array|null
- */
- private function decodePartToArray(string $part): ?array
- {
- $decoded = base64_decode(strtr($part, '-_', '+/'));
- if (false === $decoded) {
- return null;
- }
-
- $data = json_decode($decoded, true);
-
- return \is_array($data) ? $data : null;
- }
-}
diff --git a/examples/server/oauth-microsoft/README.md b/examples/server/oauth-microsoft/README.md
deleted file mode 100644
index 66d51d8e..00000000
--- a/examples/server/oauth-microsoft/README.md
+++ /dev/null
@@ -1,223 +0,0 @@
-# OAuth Microsoft Entra ID Example
-
-This example demonstrates MCP server authorization using Microsoft Entra ID (formerly Azure AD) as the OAuth 2.0 / OpenID Connect provider.
-
-## Features
-
-- JWT token validation with Microsoft Entra ID
-- Microsoft-specific validator/discovery overrides for Entra quirks
-- Protected Resource Metadata (RFC 9728)
-- MCP tools that access Microsoft claims
-- Optional Microsoft Graph API integration
-
-## Prerequisites
-
-1. **Azure Subscription** with access to Entra ID
-2. **App Registration** in Azure Portal
-
-## Azure Setup
-
-### 1. Create App Registration
-
-1. Go to [Azure Portal](https://portal.azure.com) > **Entra ID** > **App registrations**
-2. Click **New registration**
-3. Configure:
- - **Name**: `MCP Server`
- - **Supported account types**: Choose based on your needs
- - **Redirect URI**: Leave empty for now (this is a resource server)
-4. Click **Register**
-
-### 2. Configure the App
-
-After registration:
-
-1. **Copy values for `.env`**:
- - **Application (client) ID** → `AZURE_CLIENT_ID`
- - **Directory (tenant) ID** → `AZURE_TENANT_ID`
-
-2. **Expose an API** (optional, for custom scopes):
- - Go to **Expose an API**
- - Set **Application ID URI** (e.g., `api://your-client-id`)
- - Add scopes like `mcp.read`, `mcp.write`
-
-3. **Create client secret** (for Graph API calls):
- - Go to **Certificates & secrets**
- - Click **New client secret**
- - Copy the secret value → `AZURE_CLIENT_SECRET`
-
-4. **API Permissions** (for Graph API):
- - Go to **API permissions**
- - Add **Microsoft Graph** > **Delegated permissions**:
- - `User.Read` (for profile)
- - `Mail.Read` (for emails, optional)
- - Grant admin consent if required
-
-### 3. Create a Client App (for testing)
-
-Create a separate app registration for the client:
-
-1. **New registration**:
- - **Name**: `MCP Client`
- - **Redirect URI**: `http://localhost` (Public client/native)
-
-2. **Authentication**:
- - Enable **Allow public client flows** for PKCE
-
-3. **API permissions**:
- - Add permission to your MCP Server app's exposed API
-
-## Quick Start
-
-1. **Copy environment file:**
-
-```bash
-cp env.example .env
-```
-
-2. **Edit `.env` with your Azure values:**
-
-```bash
-AZURE_TENANT_ID=your-tenant-id
-AZURE_CLIENT_ID=your-client-id
-AZURE_CLIENT_SECRET=your-client-secret # Optional, for Graph API
-```
-
-3. **Start the services:**
-
-```bash
-docker compose up -d
-```
-
-4. **Get an access token:**
-
-Using Azure CLI:
-```bash
-# Login
-az login
-
-# Get token for your app
-TOKEN=$(az account get-access-token \
- --resource api://your-client-id \
- --query accessToken -o tsv)
-```
-
-Or using MSAL / OAuth flow in your client application.
-
-5. **Test the MCP server:**
-
-```bash
-# Get Protected Resource Metadata
-curl http://localhost:8000/.well-known/oauth-protected-resource
-
-# Call MCP endpoint without token (should get 401)
-curl -i http://localhost:8000/mcp
-
-# Call MCP endpoint with token
-curl -X POST http://localhost:8000/mcp \
- -H "Authorization: Bearer $TOKEN" \
- -H "Content-Type: application/json" \
- -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}'
-```
-
-## Architecture
-
-```
-┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
-│ MCP Client │────▶│ Nginx │────▶│ PHP-FPM │
-│ │ │ (port 8000) │ │ MCP Server │
-└─────────────────┘ └─────────────────┘ └─────────────────┘
- │ │
- │ Get Token │ Validate JWT
- ▼ ▼
-┌─────────────────┐ ┌─────────────────┐
-│ Microsoft │◀───────────────────────────│ JWKS Fetch │
-│ Entra ID │ │ │
-└─────────────────┘ └─────────────────┘
- │
- │ (Optional) Graph API
- ▼
-┌─────────────────┐
-│ Microsoft │
-│ Graph API │
-└─────────────────┘
-```
-
-## Files
-
-- `docker-compose.yml` - Docker Compose configuration
-- `Dockerfile` - PHP-FPM container
-- `nginx/default.conf` - Nginx configuration
-- `env.example` - Environment variables template
-- `server.php` - MCP server with OAuth middleware (uses built-in `LenientOidcDiscoveryMetadataPolicy` for metadata validation)
-- `MicrosoftJwtTokenValidator.php` - Example-specific validator for Graph/non-Graph tokens
-- `McpElements.php` - MCP tools including Graph API integration
-
-## Environment Variables
-
-| Variable | Required | Description |
-|----------|----------|-------------|
-| `AZURE_TENANT_ID` | Yes | Azure AD tenant ID |
-| `AZURE_CLIENT_ID` | Yes | Application (client) ID |
-| `AZURE_CLIENT_SECRET` | No | Client secret for Graph API calls |
-
-## Microsoft Token Structure
-
-Microsoft Entra ID tokens include these common claims:
-
-| Claim | Description |
-|-------|-------------|
-| `oid` | Object ID (unique user identifier in tenant) |
-| `tid` | Tenant ID |
-| `sub` | Subject (unique user identifier) |
-| `name` | Display name |
-| `preferred_username` | Usually the UPN |
-| `email` | Email address (if available) |
-| `upn` | User Principal Name |
-
-## Troubleshooting
-
-### "Invalid issuer" error
-
-Microsoft uses different issuer URLs depending on the token flow:
-- v2.0 endpoint (user/delegated flows): `https://login.microsoftonline.com/{tenant}/v2.0`
-- v1.0 endpoint (client credentials flow): `https://sts.windows.net/{tenant}/`
-
-This example **automatically accepts both formats** by configuring multiple issuers in the `MicrosoftJwtTokenValidator`.
-Check your token's `iss` claim to verify which format is being used.
-
-### "Invalid audience" error
-
-The `aud` claim must match `AZURE_CLIENT_ID`. For v2.0 tokens with custom scopes,
-the audience might be `api://your-client-id`.
-
-### JWKS fetch fails
-
-Microsoft's JWKS endpoint is public. Ensure your container can reach:
-`https://login.microsoftonline.com/{tenant}/discovery/v2.0/keys`
-
-### `code_challenge_methods_supported` missing in discovery metadata
-
-The default `StrictOidcDiscoveryMetadataPolicy` requires `code_challenge_methods_supported`.
-Microsoft Entra ID omits this field despite supporting PKCE with S256.
-This example uses the built-in `LenientOidcDiscoveryMetadataPolicy` which accepts missing
-`code_challenge_methods_supported` (defaults to S256 downstream).
-
-### Graph API errors
-
-1. Ensure `AZURE_CLIENT_SECRET` is set
-2. Verify API permissions have admin consent
-3. Check that the user exists in your tenant
-
-## Security Notes
-
-1. **Never commit `.env` files** - they contain secrets
-2. **Use managed identities** in Azure deployments instead of client secrets
-3. **Implement proper token refresh** in production clients
-4. **Validate scopes** for sensitive operations
-5. **Important:** `MicrosoftJwtTokenValidator` in this example accepts `nonce` Graph-style tokens via claim checks only (`iss`/`exp`/`nbf`) without signature verification. Treat this as demo-only behavior and replace it with full signature validation for production.
-
-## Cleanup
-
-```bash
-docker compose down -v
-```
diff --git a/examples/server/oauth-microsoft/docker-compose.yml b/examples/server/oauth-microsoft/docker-compose.yml
deleted file mode 100644
index 4b02d65b..00000000
--- a/examples/server/oauth-microsoft/docker-compose.yml
+++ /dev/null
@@ -1,43 +0,0 @@
-services:
- php:
- build:
- context: .
- dockerfile: Dockerfile
- container_name: mcp-php-microsoft
- volumes:
- - ../../../:/app
- working_dir: /app
- env_file:
- - .env
- environment:
- AZURE_TENANT_ID: ${AZURE_TENANT_ID:-}
- AZURE_CLIENT_ID: ${AZURE_CLIENT_ID:-}
- AZURE_CLIENT_SECRET: ${AZURE_CLIENT_SECRET:-}
- command: >
- sh -c "mkdir -p /app/examples/server/oauth-microsoft/sessions;
- chmod -R 0777 /app/examples/server/oauth-microsoft/sessions;
- touch /app/examples/server/oauth-microsoft/dev.log;
- chmod 0666 /app/examples/server/oauth-microsoft/dev.log;
- touch /app/examples/server/dev.log;
- chmod 0666 /app/examples/server/dev.log;
- composer install --no-interaction --quiet 2>/dev/null || true;
- php-fpm"
- networks:
- - mcp-network
-
- nginx:
- image: nginx:alpine
- container_name: mcp-nginx-microsoft
- ports:
- - "${MCP_HTTP_PORT:-8000}:80"
- volumes:
- - ./nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
- - ../../../:/app:ro
- depends_on:
- - php
- networks:
- - mcp-network
-
-networks:
- mcp-network:
- driver: bridge
diff --git a/examples/server/oauth-microsoft/env.example b/examples/server/oauth-microsoft/env.example
deleted file mode 100644
index 7ce041f4..00000000
--- a/examples/server/oauth-microsoft/env.example
+++ /dev/null
@@ -1,18 +0,0 @@
-# Microsoft Entra ID (Azure AD) Configuration
-# Copy this file to .env and fill in your values
-
-# Your Azure AD tenant ID
-# Find at: Azure Portal > Entra ID > Overview > Tenant ID
-AZURE_TENANT_ID=your-tenant-id-here
-
-# Application (client) ID for the MCP server app registration
-# This is the audience that tokens must be issued for
-AZURE_CLIENT_ID=your-client-id-here
-
-# Client secret for calling Microsoft Graph API (optional)
-# Only needed if your MCP tools call Graph API on behalf of users
-AZURE_CLIENT_SECRET=your-client-secret-here
-
-# Optional: Specific API permissions/scopes your MCP server accepts
-# Comma-separated list of custom scopes defined in your app registration
-# MCP_SCOPES=api://your-client-id/mcp.read,api://your-client-id/mcp.write
diff --git a/examples/server/oauth-microsoft/nginx/default.conf b/examples/server/oauth-microsoft/nginx/default.conf
deleted file mode 100644
index ad990152..00000000
--- a/examples/server/oauth-microsoft/nginx/default.conf
+++ /dev/null
@@ -1,25 +0,0 @@
-server {
- listen 80;
- server_name localhost;
- root /app/examples/server/oauth-microsoft;
-
- # Route all requests through PHP
- location / {
- try_files $uri /server.php$is_args$args;
- }
-
- # PHP processing
- location ~ \.php$ {
- fastcgi_pass php:9000;
- fastcgi_index server.php;
- fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
- include fastcgi_params;
-
- # Pass all request info
- fastcgi_param REQUEST_URI $request_uri;
- fastcgi_param QUERY_STRING $query_string;
- fastcgi_param REQUEST_METHOD $request_method;
- fastcgi_param CONTENT_TYPE $content_type;
- fastcgi_param CONTENT_LENGTH $content_length;
- }
-}
diff --git a/examples/server/oauth-microsoft/server.php b/examples/server/oauth-microsoft/server.php
deleted file mode 100644
index c4fae598..00000000
--- a/examples/server/oauth-microsoft/server.php
+++ /dev/null
@@ -1,95 +0,0 @@
-setServerInfo('OAuth Microsoft Example', '1.0.0')
- ->setLogger(logger())
- ->setSession(new FileSessionStore(__DIR__.'/sessions'))
- ->setDiscovery(__DIR__)
- ->build();
-
-$transport = new StreamableHttpTransport(
- (new Psr17Factory())->createServerRequestFromGlobals(),
- logger: logger(),
- middleware: [
- ...StreamableHttpTransport::defaultMiddleware(),
- $oauthProxyMiddleware,
- $metadataMiddleware,
- $authMiddleware,
- new OAuthRequestMetaMiddleware(),
- ],
-);
-
-$response = $server->run($transport);
-
-(new SapiEmitter())->emit($response);
diff --git a/examples/server/oauth-microsoft/tests/Unit/MicrosoftJwtTokenValidatorTest.php b/examples/server/oauth-microsoft/tests/Unit/MicrosoftJwtTokenValidatorTest.php
deleted file mode 100644
index b8ae5112..00000000
--- a/examples/server/oauth-microsoft/tests/Unit/MicrosoftJwtTokenValidatorTest.php
+++ /dev/null
@@ -1,300 +0,0 @@
-
- */
-class MicrosoftJwtTokenValidatorTest extends TestCase
-{
- #[TestDox('non-Graph Microsoft token is validated via JWKS')]
- public function testNonGraphTokenUsesStandardJwtValidation(): void
- {
- $factory = new Psr17Factory();
- [$privateKeyPem, $publicJwk] = $this->generateRsaKeypairAsJwk('test-kid');
-
- $jwksUri = 'https://login.microsoftonline.com/common/discovery/v2.0/keys';
- $httpClient = $this->createHttpClientMock([
- $factory->createResponse(200)
- ->withHeader('Content-Type', 'application/json')
- ->withBody($factory->createStream(json_encode(['keys' => [$publicJwk]], \JSON_THROW_ON_ERROR))),
- ]);
-
- $jwtTokenValidator = new JwtTokenValidator(
- issuer: 'https://login.microsoftonline.com/tenant-id/v2.0',
- audience: 'mcp-api',
- jwksProvider: new JwksProvider(discovery: $this->createDiscoveryStub(), httpClient: $httpClient, requestFactory: $factory),
- jwksUri: $jwksUri,
- scopeClaim: 'scp',
- );
- $validator = new MicrosoftJwtTokenValidator(jwtTokenValidator: $jwtTokenValidator);
-
- $token = JWT::encode(
- [
- 'iss' => 'https://login.microsoftonline.com/tenant-id/v2.0',
- 'aud' => 'mcp-api',
- 'sub' => 'user-123',
- 'scp' => 'files.read files.write',
- 'iat' => time() - 10,
- 'exp' => time() + 600,
- ],
- $privateKeyPem,
- 'RS256',
- keyId: 'test-kid',
- );
-
- $result = $validator->validate($token);
-
- $this->assertTrue($result->isAllowed());
- $this->assertSame(['files.read', 'files.write'], $result->getAttributes()['oauth.scopes']);
- $this->assertSame('user-123', $result->getAttributes()['oauth.subject']);
- $this->assertArrayNotHasKey('oauth.graph_token', $result->getAttributes());
- }
-
- #[TestDox('Graph token with nonce header is validated by claims only')]
- public function testGraphTokenWithNonceHeaderIsAllowed(): void
- {
- $factory = new Psr17Factory();
- $token = $this->buildGraphToken([
- 'iss' => 'https://login.microsoftonline.com/tenant-id/v2.0',
- 'aud' => 'mcp-api',
- 'sub' => 'user-graph',
- 'scp' => 'files.read files.write',
- 'iat' => time() - 10,
- 'exp' => time() + 600,
- ]);
-
- $jwtTokenValidator = new JwtTokenValidator(
- issuer: ['https://auth.example.com'],
- audience: ['mcp-api'],
- jwksProvider: new JwksProvider(discovery: $this->createDiscoveryStub(),
- httpClient: $this->createHttpClientMock([$factory->createResponse(500)], 0),
- requestFactory: $factory
- ),
- jwksUri: 'https://unused.example.com/jwks',
- scopeClaim: 'scp',
- );
- $validator = new MicrosoftJwtTokenValidator(
- jwtTokenValidator: $jwtTokenValidator,
- scopeClaim: 'scp',
- );
-
- $result = $validator->validate($token);
-
- $this->assertTrue($result->isAllowed());
- $this->assertTrue($result->getAttributes()['oauth.graph_token']);
- $this->assertSame(['files.read', 'files.write'], $result->getAttributes()['oauth.scopes']);
- $this->assertSame('user-graph', $result->getAttributes()['oauth.subject']);
- }
-
- #[TestDox('Graph token with invalid payload is unauthorized')]
- public function testGraphTokenInvalidPayloadIsUnauthorized(): void
- {
- $factory = new Psr17Factory();
- $header = $this->b64urlEncode(json_encode([
- 'alg' => 'none',
- 'typ' => 'JWT',
- 'nonce' => 'abc',
- ], \JSON_THROW_ON_ERROR));
- $token = $header.'..';
-
- $jwtTokenValidator = new JwtTokenValidator(
- issuer: ['https://auth.example.com'],
- audience: ['mcp-api'],
- jwksProvider: new JwksProvider(discovery: $this->createDiscoveryStub(),
- httpClient: $this->createHttpClientMock([$factory->createResponse(500)], 0),
- requestFactory: $factory
- ),
- jwksUri: 'https://unused.example.com/jwks',
- scopeClaim: 'scp',
- );
- $validator = new MicrosoftJwtTokenValidator(
- jwtTokenValidator: $jwtTokenValidator,
- scopeClaim: 'scp',
- );
-
- $result = $validator->validate($token);
-
- $this->assertFalse($result->isAllowed());
- $this->assertSame(401, $result->getStatusCode());
- $this->assertSame('invalid_token', $result->getError());
- $this->assertSame('Invalid token payload.', $result->getErrorDescription());
- }
-
- #[TestDox('Graph token with invalid issuer is unauthorized')]
- public function testGraphTokenInvalidIssuerIsUnauthorized(): void
- {
- $factory = new Psr17Factory();
- $token = $this->buildGraphToken([
- 'iss' => 'https://evil.example.com',
- 'aud' => 'mcp-api',
- 'sub' => 'user-graph',
- 'scp' => 'files.read',
- 'iat' => time() - 10,
- 'exp' => time() + 600,
- ]);
-
- $jwtTokenValidator = new JwtTokenValidator(
- issuer: ['https://auth.example.com'],
- audience: ['mcp-api'],
- jwksProvider: new JwksProvider(discovery: $this->createDiscoveryStub(),
- httpClient: $this->createHttpClientMock([$factory->createResponse(500)], 0),
- requestFactory: $factory
- ),
- jwksUri: 'https://unused.example.com/jwks',
- scopeClaim: 'scp',
- );
- $validator = new MicrosoftJwtTokenValidator(
- jwtTokenValidator: $jwtTokenValidator,
- scopeClaim: 'scp',
- );
-
- $result = $validator->validate($token);
-
- $this->assertFalse($result->isAllowed());
- $this->assertSame(401, $result->getStatusCode());
- $this->assertSame('invalid_token', $result->getError());
- $this->assertSame('Invalid token issuer for Graph token.', $result->getErrorDescription());
- }
-
- #[TestDox('scope checks are delegated to base JwtTokenValidator')]
- public function testRequireScopesDelegatesToJwtTokenValidator(): void
- {
- $factory = new Psr17Factory();
- $jwtTokenValidator = new JwtTokenValidator(
- issuer: ['https://auth.example.com'],
- audience: ['mcp-api'],
- jwksProvider: new JwksProvider(discovery: $this->createDiscoveryStub(),
- httpClient: $this->createHttpClientMock([], 0),
- requestFactory: $factory,
- ),
- jwksUri: 'https://unused.example.com/jwks',
- scopeClaim: 'scp',
- );
- $validator = new MicrosoftJwtTokenValidator(
- jwtTokenValidator: $jwtTokenValidator,
- scopeClaim: 'scp',
- );
-
- $result = AuthorizationResult::allow([
- 'oauth.scopes' => ['files.read'],
- ]);
- $scoped = $validator->requireScopes($result, ['files.read', 'files.write']);
-
- $this->assertFalse($scoped->isAllowed());
- $this->assertSame(403, $scoped->getStatusCode());
- $this->assertSame('insufficient_scope', $scoped->getError());
- }
-
- /**
- * @param array $claims
- */
- private function buildGraphToken(array $claims): string
- {
- $header = $this->b64urlEncode(json_encode([
- 'alg' => 'none',
- 'typ' => 'JWT',
- 'nonce' => 'abc',
- ], \JSON_THROW_ON_ERROR));
-
- $payload = $this->b64urlEncode(json_encode($claims, \JSON_THROW_ON_ERROR));
-
- return $header.'.'.$payload.'.';
- }
-
- /**
- * @return array{0: string, 1: array}
- */
- private function generateRsaKeypairAsJwk(string $kid): array
- {
- $key = openssl_pkey_new([
- 'private_key_type' => \OPENSSL_KEYTYPE_RSA,
- 'private_key_bits' => 2048,
- ]);
-
- if (false === $key) {
- $this->fail('Failed to generate RSA keypair via OpenSSL.');
- }
-
- $privateKeyPem = '';
- if (!openssl_pkey_export($key, $privateKeyPem)) {
- $this->fail('Failed to export RSA private key.');
- }
-
- $details = openssl_pkey_get_details($key);
- if (false === $details || !isset($details['rsa']['n'], $details['rsa']['e'])) {
- $this->fail('Failed to read RSA key details.');
- }
-
- $n = $this->b64urlEncode($details['rsa']['n']);
- $e = $this->b64urlEncode($details['rsa']['e']);
-
- $publicJwk = [
- 'kty' => 'RSA',
- 'kid' => $kid,
- 'use' => 'sig',
- 'alg' => 'RS256',
- 'n' => $n,
- 'e' => $e,
- ];
-
- return [$privateKeyPem, $publicJwk];
- }
-
- private function b64urlEncode(string $data): string
- {
- return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
- }
-
- private function createDiscoveryStub(): OidcDiscoveryInterface
- {
- return $this->createStub(OidcDiscoveryInterface::class);
- }
-
- /**
- * @param list $responses
- */
- private function createHttpClientMock(array $responses, ?int $expectedCalls = null): ClientInterface
- {
- $expectedCalls ??= \count($responses);
-
- $client = $this->createMock(ClientInterface::class);
- $client
- ->expects($this->exactly($expectedCalls))
- ->method('sendRequest')
- ->with($this->isInstanceOf(RequestInterface::class))
- ->willReturnCallback(static function () use (&$responses): ResponseInterface {
- if ([] === $responses) {
- throw new \RuntimeException('No more mocked responses available.');
- }
-
- return array_shift($responses);
- });
-
- return $client;
- }
-}
diff --git a/examples/server/schema-showcase/SchemaShowcaseElements.php b/examples/server/schema-showcase/SchemaShowcaseElements.php
deleted file mode 100644
index 294d9680..00000000
--- a/examples/server/schema-showcase/SchemaShowcaseElements.php
+++ /dev/null
@@ -1,465 +0,0 @@
-
- */
- #[McpTool(
- name: 'format_text',
- description: 'Formats text with validation constraints. Text must be 5-100 characters and contain only letters, numbers, spaces, and basic punctuation.'
- )]
- public function formatText(
- #[Schema(
- type: 'string',
- description: 'The text to format',
- minLength: 5,
- maxLength: 100,
- pattern: '^[a-zA-Z0-9\s\.,!?\-]+$'
- )]
- string $text,
-
- #[Schema(
- type: 'string',
- description: 'Format style',
- enum: ['uppercase', 'lowercase', 'title', 'sentence']
- )]
- string $format = 'sentence',
- ): array {
- $this->logger->info(\sprintf('Tool format_text called with text: %s and format: %s', $text, $format));
-
- $formatted = match ($format) {
- 'uppercase' => strtoupper($text),
- 'lowercase' => strtolower($text),
- 'title' => ucwords(strtolower($text)),
- 'sentence' => ucfirst(strtolower($text)),
- default => $text,
- };
-
- return [
- 'original' => $text,
- 'formatted' => $formatted,
- 'length' => \strlen($text),
- 'format_applied' => $format,
- ];
- }
-
- /**
- * Performs mathematical operations with numeric constraints.
- *
- * Demonstrates: METHOD-LEVEL Schema
- *
- * @return array
- */
- #[McpTool(name: 'calculate_range')]
- #[Schema(
- type: 'object',
- properties: [
- 'first' => [
- 'type' => 'number',
- 'description' => 'First number (must be between 0 and 1000)',
- 'minimum' => 0,
- 'maximum' => 1000,
- ],
- 'second' => [
- 'type' => 'number',
- 'description' => 'Second number (must be between 0 and 1000)',
- 'minimum' => 0,
- 'maximum' => 1000,
- ],
- 'operation' => [
- 'type' => 'string',
- 'description' => 'Operation to perform',
- 'enum' => ['add', 'subtract', 'multiply', 'divide', 'power'],
- ],
- 'precision' => [
- 'type' => 'integer',
- 'description' => 'Decimal precision (must be multiple of 2, between 0-10)',
- 'minimum' => 0,
- 'maximum' => 10,
- 'multipleOf' => 2,
- ],
- ],
- required: ['first', 'second', 'operation'],
- )]
- public function calculateRange(float $first, float $second, string $operation, int $precision = 2): array
- {
- $this->logger->info(\sprintf('Tool calculate_range called with: %f %s %f (precision: %d)', $first, $operation, $second, $precision));
-
- $result = match ($operation) {
- 'add' => $first + $second,
- 'subtract' => $first - $second,
- 'multiply' => $first * $second,
- 'divide' => 0 != $second ? $first / $second : null,
- 'power' => $first ** $second,
- default => null,
- };
-
- if (null === $result) {
- return [
- 'error' => 'divide' === $operation ? 'Division by zero' : 'Invalid operation',
- 'inputs' => compact('first', 'second', 'operation', 'precision'),
- ];
- }
-
- return [
- 'result' => round($result, $precision),
- 'operation' => "$first $operation $second",
- 'precision' => $precision,
- 'within_bounds' => $result >= 0 && $result <= 1000000,
- ];
- }
-
- /**
- * Processes user profile data with object schema validation.
- * Demonstrates: object properties, required fields, additionalProperties.
- *
- * @param array $profile
- *
- * @return array
- */
- #[McpTool(
- name: 'validate_profile',
- description: 'Validates and processes user profile data with strict schema requirements.'
- )]
- public function validateProfile(
- #[Schema(
- type: 'object',
- description: 'User profile information',
- properties: [
- 'name' => [
- 'type' => 'string',
- 'minLength' => 2,
- 'maxLength' => 50,
- 'description' => 'Full name',
- ],
- 'email' => [
- 'type' => 'string',
- 'format' => 'email',
- 'description' => 'Valid email address',
- ],
- 'age' => [
- 'type' => 'integer',
- 'minimum' => 13,
- 'maximum' => 120,
- 'description' => 'Age in years',
- ],
- 'role' => [
- 'type' => 'string',
- 'enum' => ['user', 'admin', 'moderator', 'guest'],
- 'description' => 'User role',
- ],
- 'preferences' => [
- 'type' => 'object',
- 'properties' => [
- 'notifications' => ['type' => 'boolean'],
- 'theme' => ['type' => 'string', 'enum' => ['light', 'dark', 'auto']],
- ],
- 'additionalProperties' => false,
- ],
- ],
- required: ['name', 'email', 'age'],
- additionalProperties: true
- )]
- array $profile,
- ): array {
- $this->logger->info(\sprintf('Tool validate_profile called: %s', json_encode($profile)));
-
- $errors = [];
- $warnings = [];
-
- // Additional business logic validation
- if (isset($profile['age']) && $profile['age'] < 18 && ($profile['role'] ?? 'user') === 'admin') {
- $errors[] = 'Admin role requires age 18 or older';
- }
-
- if (isset($profile['email']) && !filter_var($profile['email'], \FILTER_VALIDATE_EMAIL)) {
- $errors[] = 'Invalid email format';
- }
-
- if (!isset($profile['role'])) {
- $warnings[] = 'No role specified, defaulting to "user"';
- $profile['role'] = 'user';
- }
-
- return [
- 'valid' => empty($errors),
- 'profile' => $profile,
- 'errors' => $errors,
- 'warnings' => $warnings,
- 'processed_at' => date('Y-m-d H:i:s'),
- ];
- }
-
- /**
- * Manages a list of items with array constraints.
- * Demonstrates: array items, minItems, maxItems, uniqueItems.
- *
- * @param string[] $items
- *
- * @return array
- */
- #[McpTool(
- name: 'manage_list',
- description: 'Manages a list of items with size and uniqueness constraints.'
- )]
- public function manageList(
- #[Schema(
- type: 'array',
- description: 'List of items to manage (2-10 unique strings)',
- items: [
- 'type' => 'string',
- 'minLength' => 1,
- 'maxLength' => 30,
- ],
- minItems: 2,
- maxItems: 10,
- uniqueItems: true
- )]
- array $items,
-
- #[Schema(
- type: 'string',
- description: 'Action to perform on the list',
- enum: ['sort', 'reverse', 'shuffle', 'deduplicate', 'filter_short', 'filter_long']
- )]
- string $action = 'sort',
- ): array {
- $this->logger->info(\sprintf('Tool manage_list called with %d items, action: %s', \count($items), $action));
-
- $original = $items;
- $processed = $items;
-
- switch ($action) {
- case 'sort':
- sort($processed);
- break;
- case 'reverse':
- $processed = array_reverse($processed);
- break;
- case 'shuffle':
- shuffle($processed);
- break;
- case 'deduplicate':
- $processed = array_unique($processed);
- break;
- case 'filter_short':
- $processed = array_filter($processed, static fn ($item) => \strlen($item) <= 10);
- break;
- case 'filter_long':
- $processed = array_filter($processed, static fn ($item) => \strlen($item) > 10);
- break;
- }
-
- return [
- 'original_count' => \count($original),
- 'processed_count' => \count($processed),
- 'action' => $action,
- 'original' => $original,
- 'processed' => array_values($processed), // Re-index array
- 'stats' => [
- 'average_length' => \count($processed) > 0 ? round(array_sum(array_map('strlen', $processed)) / \count($processed), 2) : 0,
- 'shortest' => \count($processed) > 0 ? min(array_map('strlen', $processed)) : 0,
- 'longest' => \count($processed) > 0 ? max(array_map('strlen', $processed)) : 0,
- ],
- ];
- }
-
- /**
- * Generates configuration with format validation.
- * Demonstrates: format constraints (date-time, uri, etc).
- *
- * @return array
- */
- #[McpTool(
- name: 'generate_config',
- description: 'Generates configuration with format-validated inputs.'
- )]
- public function generateConfig(
- #[Schema(
- type: 'string',
- description: 'Application name (alphanumeric with hyphens)',
- minLength: 3,
- maxLength: 20,
- pattern: '^[a-zA-Z0-9\-]+$'
- )]
- string $appName,
-
- #[Schema(
- type: 'string',
- description: 'Valid URL for the application',
- format: 'uri'
- )]
- string $baseUrl,
-
- #[Schema(
- type: 'string',
- description: 'Environment type',
- enum: ['development', 'staging', 'production']
- )]
- string $environment = 'development',
-
- #[Schema(
- type: 'boolean',
- description: 'Enable debug mode'
- )]
- bool $debug = true,
-
- #[Schema(
- type: 'integer',
- description: 'Port number (1024-65535)',
- minimum: 1024,
- maximum: 65535
- )]
- int $port = 8080,
- ): array {
- $this->logger->info(\sprintf('Tool generate_config called for app: %s at %s', $appName, $baseUrl));
-
- $config = [
- 'app' => [
- 'name' => $appName,
- 'env' => $environment,
- 'debug' => $debug,
- 'url' => $baseUrl,
- 'port' => $port,
- ],
- 'generated_at' => date('c'), // ISO 8601 format
- 'version' => '1.0.0',
- 'features' => [
- 'logging' => 'production' !== $environment || $debug,
- 'caching' => 'production' === $environment,
- 'analytics' => 'production' === $environment,
- 'rate_limiting' => 'development' !== $environment,
- ],
- ];
-
- return [
- 'success' => true,
- 'config' => $config,
- 'validation' => [
- 'app_name_valid' => 1 === preg_match('/^[a-zA-Z0-9\-]+$/', $appName),
- 'url_valid' => false !== filter_var($baseUrl, \FILTER_VALIDATE_URL),
- 'port_in_range' => $port >= 1024 && $port <= 65535,
- ],
- ];
- }
-
- /**
- * Processes time-based data with date-time format validation.
- * Demonstrates: date-time format, exclusiveMinimum, exclusiveMaximum.
- *
- * @param string[] $attendees
- *
- * @return array
- */
- #[McpTool(
- name: 'schedule_event',
- description: 'Schedules an event with time validation and constraints.'
- )]
- public function scheduleEvent(
- #[Schema(
- type: 'string',
- description: 'Event title (3-50 characters)',
- minLength: 3,
- maxLength: 50
- )]
- string $title,
-
- #[Schema(
- type: 'string',
- description: 'Event start time in ISO 8601 format',
- format: 'date-time'
- )]
- string $startTime,
-
- #[Schema(
- type: 'number',
- description: 'Duration in hours (minimum 0.5, maximum 24)',
- minimum: 0.5,
- maximum: 24,
- multipleOf: 0.5
- )]
- float $durationHours,
-
- #[Schema(
- type: 'string',
- description: 'Event priority level',
- enum: ['low', 'medium', 'high', 'urgent']
- )]
- string $priority = 'medium',
-
- #[Schema(
- type: 'array',
- description: 'List of attendee email addresses',
- items: [
- 'type' => 'string',
- 'format' => 'email',
- ],
- maxItems: 20
- )]
- array $attendees = [],
- ): array {
- $this->logger->info(\sprintf('Tool schedule_event called: %s at %s for %.1f hours', $title, $startTime, $durationHours));
-
- $start = \DateTime::createFromFormat(\DateTime::ISO8601, $startTime);
- if (!$start) {
- $start = \DateTime::createFromFormat('Y-m-d\TH:i:s\Z', $startTime);
- }
-
- if (!$start) {
- return [
- 'success' => false,
- 'error' => 'Invalid date-time format. Use ISO 8601 format.',
- 'example' => '2024-01-15T14:30:00Z',
- ];
- }
-
- $end = clone $start;
- $end->add(new \DateInterval('PT'.($durationHours * 60).'M'));
-
- $event = [
- 'id' => uniqid('event_'),
- 'title' => $title,
- 'start_time' => $start->format('c'),
- 'end_time' => $end->format('c'),
- 'duration_hours' => $durationHours,
- 'priority' => $priority,
- 'attendees' => $attendees,
- 'created_at' => date('c'),
- ];
-
- return [
- 'success' => true,
- 'event' => $event,
- 'info' => [
- 'attendee_count' => \count($attendees),
- 'is_all_day' => $durationHours >= 24,
- 'is_future' => $start > new \DateTime(),
- 'timezone_note' => 'Times are in UTC',
- ],
- ];
- }
-}
diff --git a/examples/server/schema-showcase/server.php b/examples/server/schema-showcase/server.php
deleted file mode 100644
index 86c2bf77..00000000
--- a/examples/server/schema-showcase/server.php
+++ /dev/null
@@ -1,36 +0,0 @@
-#!/usr/bin/env php
-setServerInfo(
- 'Schema Showcase',
- '1.0.0',
- 'A showcase server demonstrating MCP schema capabilities.',
- [new Icon('https://www.php.net/images/logos/php-logo-white.svg', 'image/svg+xml', ['any'])],
- 'https://github.com/modelcontextprotocol/php-sdk',
- )
- ->setContainer(container())
- ->setLogger(logger())
- ->setSession(new FileSessionStore(__DIR__.'/sessions'))
- ->setDiscovery(__DIR__)
- ->build();
-
-$response = $server->run(transport());
-
-shutdown($response);
diff --git a/phpdoc.dist.xml b/phpdoc.dist.xml
deleted file mode 100644
index b209b815..00000000
--- a/phpdoc.dist.xml
+++ /dev/null
@@ -1,44 +0,0 @@
-
-
- MCP PHP SDK
-
- .phpdoc/build
-
-
- latest
-
-
- src
-
- api
-
- vendor/**/*
- tests/**/*
-
-
- phpstan-type
- phpstan-type-import
- template
- template-covariant
- template-extends
- template-implements
- extends
- implements
-
-
-
-
- docs
-
- /
-
-
-
-
-
-
diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon
deleted file mode 100644
index f5901b28..00000000
--- a/phpstan-baseline.neon
+++ /dev/null
@@ -1,7 +0,0 @@
-parameters:
- ignoreErrors:
- -
- message: '#^Method Mcp\\Schema\\Result\\ReadResourceResult\:\:jsonSerialize\(\) should return array\{contents\: array\\} but returns array\{contents\: array\\}\.$#'
- identifier: return.type
- count: 1
- path: src/Schema/Result/ReadResourceResult.php
diff --git a/phpstan.dist.neon b/phpstan.dist.neon
deleted file mode 100644
index 259c4686..00000000
--- a/phpstan.dist.neon
+++ /dev/null
@@ -1,24 +0,0 @@
-includes:
- - phpstan-baseline.neon
-
-parameters:
- level: 6
- paths:
- - examples/
- - src/
- - tests/
- excludePaths:
- - examples/cli/vendor/* (?)
- - tests/Unit/Capability/Discovery/Fixtures/
- - tests/Unit/Capability/Discovery/SchemaGeneratorFixture.php
- treatPhpDocTypesAsCertain: false
- ignoreErrors:
- -
- identifier: missingType.iterableValue
- path: tests/
-
- # These errors should actually be fixed, but are ignored for now
- -
- identifier: missingType.iterableValue
- path: src/Capability/Discovery/SchemaGenerator.php
- count: 12
diff --git a/phpunit.xml.dist b/phpunit.xml.dist
deleted file mode 100644
index eb07b79a..00000000
--- a/phpunit.xml.dist
+++ /dev/null
@@ -1,31 +0,0 @@
-
-
-
-
-
- tests/Unit
-
-
- tests/Integration
-
-
- examples/server/oauth-microsoft/tests
-
-
- tests/Inspector
-
-
-
-
-
- src
-
-
-
diff --git a/server-conformance.json b/server-conformance.json
new file mode 100644
index 00000000..983b149d
--- /dev/null
+++ b/server-conformance.json
@@ -0,0 +1,6 @@
+{
+ "schemaVersion": 1,
+ "label": "server conformance",
+ "message": "39/39 (100%)",
+ "color": "brightgreen"
+}
diff --git a/src/Capability/Attribute/CompletionProvider.php b/src/Capability/Attribute/CompletionProvider.php
deleted file mode 100644
index 9e8dc802..00000000
--- a/src/Capability/Attribute/CompletionProvider.php
+++ /dev/null
@@ -1,38 +0,0 @@
-
- */
-#[\Attribute(\Attribute::TARGET_PARAMETER)]
-class CompletionProvider
-{
- /**
- * @param class-string|ProviderInterface|null $provider if a class-string, it will be resolved
- * from the container at the point of use
- * @param ?array $values a list of values to use for completion
- */
- public function __construct(
- public ?string $providerClass = null,
- public string|ProviderInterface|null $provider = null,
- public ?array $values = null,
- public ?string $enum = null,
- ) {
- if (1 !== \count(array_filter([$provider, $values, $enum]))) {
- throw new InvalidArgumentException('Only one of provider, values, or enum can be set.');
- }
- }
-}
diff --git a/src/Capability/Attribute/McpPrompt.php b/src/Capability/Attribute/McpPrompt.php
deleted file mode 100644
index cd47fe2c..00000000
--- a/src/Capability/Attribute/McpPrompt.php
+++ /dev/null
@@ -1,40 +0,0 @@
-
- */
-#[\Attribute(\Attribute::TARGET_METHOD | \Attribute::TARGET_CLASS)]
-class McpPrompt
-{
- /**
- * @param ?string $name overrides the prompt name (defaults to method name)
- * @param ?string $title Optional human-readable title for display in UI
- * @param ?string $description Optional description of the prompt. Defaults to method DocBlock summary.
- * @param ?Icon[] $icons Optional list of icon URLs representing the prompt
- * @param ?array $meta Optional metadata
- */
- public function __construct(
- public ?string $name = null,
- public ?string $title = null,
- public ?string $description = null,
- public ?array $icons = null,
- public ?array $meta = null,
- ) {
- }
-}
diff --git a/src/Capability/Attribute/McpResource.php b/src/Capability/Attribute/McpResource.php
deleted file mode 100644
index 0f516576..00000000
--- a/src/Capability/Attribute/McpResource.php
+++ /dev/null
@@ -1,49 +0,0 @@
-
- */
-#[\Attribute(\Attribute::TARGET_METHOD | \Attribute::TARGET_CLASS)]
-class McpResource
-{
- /**
- * @param string $uri the specific URI identifying this resource instance
- * @param ?string $name a short identifier for this resource; defaults to the method name
- * @param ?string $title optional human-readable title for display in UI
- * @param ?string $description optional description; defaults to class DocBlock summary
- * @param ?string $mimeType the MIME type, if known and constant for this resource
- * @param ?int $size the size in bytes, if known and constant
- * @param ?Annotations $annotations optional annotations describing the resource
- * @param ?Icon[] $icons optional icons representing the resource
- * @param ?array $meta optional metadata
- */
- public function __construct(
- public string $uri,
- public ?string $name = null,
- public ?string $title = null,
- public ?string $description = null,
- public ?string $mimeType = null,
- public ?int $size = null,
- public ?Annotations $annotations = null,
- public ?array $icons = null,
- public ?array $meta = null,
- ) {
- }
-}
diff --git a/src/Capability/Attribute/McpResourceTemplate.php b/src/Capability/Attribute/McpResourceTemplate.php
deleted file mode 100644
index 6a2044d8..00000000
--- a/src/Capability/Attribute/McpResourceTemplate.php
+++ /dev/null
@@ -1,44 +0,0 @@
-
- */
-#[\Attribute(\Attribute::TARGET_METHOD | \Attribute::TARGET_CLASS)]
-class McpResourceTemplate
-{
- /**
- * @param string $uriTemplate the URI template string (RFC 6570)
- * @param ?string $name a short identifier for the template type; defaults to the method name
- * @param ?string $title optional human-readable title for display in UI
- * @param ?string $description optional description; defaults to class DocBlock summary
- * @param ?string $mimeType optional default MIME type for matching resources
- * @param ?Annotations $annotations optional annotations describing the resource template
- * @param ?array $meta optional metadata
- */
- public function __construct(
- public string $uriTemplate,
- public ?string $name = null,
- public ?string $title = null,
- public ?string $description = null,
- public ?string $mimeType = null,
- public ?Annotations $annotations = null,
- public ?array $meta = null,
- ) {
- }
-}
diff --git a/src/Capability/Attribute/McpTool.php b/src/Capability/Attribute/McpTool.php
deleted file mode 100644
index 1c71e2cd..00000000
--- a/src/Capability/Attribute/McpTool.php
+++ /dev/null
@@ -1,42 +0,0 @@
-
- */
-#[\Attribute(\Attribute::TARGET_METHOD | \Attribute::TARGET_CLASS)]
-class McpTool
-{
- /**
- * @param string|null $name The name of the tool (defaults to the method name)
- * @param string|null $title Optional human-readable title for display in UI
- * @param string|null $description The description of the tool (defaults to the DocBlock/inferred)
- * @param ToolAnnotations|null $annotations Optional annotations describing tool behavior
- * @param ?Icon[] $icons Optional list of icon URLs representing the tool
- * @param ?array $meta Optional metadata
- * @param array $outputSchema Optional JSON Schema object for defining the expected output structure
- */
- public function __construct(
- public ?string $name = null,
- public ?string $title = null,
- public ?string $description = null,
- public ?ToolAnnotations $annotations = null,
- public ?array $icons = null,
- public ?array $meta = null,
- public ?array $outputSchema = null,
- ) {
- }
-}
diff --git a/src/Capability/Attribute/Schema.php b/src/Capability/Attribute/Schema.php
deleted file mode 100644
index 80ec4b53..00000000
--- a/src/Capability/Attribute/Schema.php
+++ /dev/null
@@ -1,258 +0,0 @@
-,
- * type?: string,
- * description?: string,
- * enum?: array,
- * format?: string,
- * minLength?: int,
- * maxLength?: int,
- * pattern?: string,
- * minimum?: int,
- * maximum?: int,
- * exclusiveMinimum?: int,
- * exclusiveMaximum?: int,
- * multipleOf?: int|float,
- * items?: array,
- * minItems?: int,
- * maxItems?: int,
- * uniqueItems?: bool,
- * properties?: array,
- * required?: array,
- * additionalProperties?: bool|array,
- * }
- *
- * @author Kyrian Obikwelu
- */
-#[\Attribute(\Attribute::TARGET_METHOD | \Attribute::TARGET_PARAMETER)]
-class Schema
-{
- /**
- * The complete JSON schema array.
- * If provided, it takes precedence over individual properties like $type, $properties, etc.
- *
- * @var ?array
- */
- public ?array $definition = null;
-
- /**
- * Alternatively, provide individual top-level schema keywords.
- * These are used if $definition is null.
- */
- public ?string $type = null;
- public ?string $description = null;
- public mixed $default = null;
- /**
- * @var ?array
- */
- public ?array $enum = null; // list of allowed values
- public ?string $format = null; // e.g., 'email', 'date-time'
-
- // Constraints for string
- public ?int $minLength = null;
- public ?int $maxLength = null;
- public ?string $pattern = null;
-
- // Constraints for number/integer
- public int|float|null $minimum = null;
- public int|float|null $maximum = null;
- public ?bool $exclusiveMinimum = null;
- public ?bool $exclusiveMaximum = null;
- public int|float|null $multipleOf = null;
-
- // Constraints for array
- /**
- * @var ?array
- */
- public ?array $items = null; // JSON schema for array items
- public ?int $minItems = null;
- public ?int $maxItems = null;
- public ?bool $uniqueItems = null;
-
- // Constraints for object (primarily used when Schema is on a method or an object-typed parameter)
- /**
- * @var ?array
- */
- public ?array $properties = null; // [propertyName => [schema array], ...]
- /**
- * @var ?array
- */
- public ?array $required = null; // [propertyName, ...]
- /**
- * @var bool|array|null
- */
- public bool|array|null $additionalProperties = null; // true, false, or a schema array
-
- /**
- * @param ?array $definition A complete JSON schema array. If provided, other parameters are ignored.
- * @param ?string $type the JSON schema type
- * @param ?string $description description of the element
- * @param ?array $enum allowed enum values
- * @param ?string $format String format (e.g., 'date-time', 'email').
- * @param ?int $minLength minimum length for strings
- * @param ?int $maxLength maximum length for strings
- * @param ?string $pattern regex pattern for strings
- * @param int|float|null $minimum minimum value for numbers/integers
- * @param int|float|null $maximum maximum value for numbers/integers
- * @param ?bool $exclusiveMinimum exclusive minimum
- * @param ?bool $exclusiveMaximum exclusive maximum
- * @param int|float|null $multipleOf must be a multiple of this value
- * @param ?array $items JSON Schema for items if type is 'array'
- * @param ?int $minItems minimum items for an array
- * @param ?int $maxItems maximum items for an array
- * @param ?bool $uniqueItems whether array items must be unique
- * @param ?array $properties Property definitions if type is 'object'. [name => schema_array].
- * @param ?array $required list of required properties for an object
- * @param bool|array|null $additionalProperties policy for additional properties in an object
- */
- public function __construct(
- ?array $definition = null,
- ?string $type = null,
- ?string $description = null,
- ?array $enum = null,
- ?string $format = null,
- ?int $minLength = null,
- ?int $maxLength = null,
- ?string $pattern = null,
- int|float|null $minimum = null,
- int|float|null $maximum = null,
- ?bool $exclusiveMinimum = null,
- ?bool $exclusiveMaximum = null,
- int|float|null $multipleOf = null,
- ?array $items = null,
- ?int $minItems = null,
- ?int $maxItems = null,
- ?bool $uniqueItems = null,
- ?array $properties = null,
- ?array $required = null,
- bool|array|null $additionalProperties = null,
- ) {
- if (null !== $definition) {
- $this->definition = $definition;
- } else {
- $this->type = $type;
- $this->description = $description;
- $this->enum = $enum;
- $this->format = $format;
- $this->minLength = $minLength;
- $this->maxLength = $maxLength;
- $this->pattern = $pattern;
- $this->minimum = $minimum;
- $this->maximum = $maximum;
- $this->exclusiveMinimum = $exclusiveMinimum;
- $this->exclusiveMaximum = $exclusiveMaximum;
- $this->multipleOf = $multipleOf;
- $this->items = $items;
- $this->minItems = $minItems;
- $this->maxItems = $maxItems;
- $this->uniqueItems = $uniqueItems;
- $this->properties = $properties;
- $this->required = $required;
- $this->additionalProperties = $additionalProperties;
- }
- }
-
- /**
- * Converts the attribute's definition to a JSON schema array.
- *
- * @return SchemaAttributeData
- */
- public function toArray(): array
- {
- if (null !== $this->definition) {
- return [
- 'definition' => $this->definition,
- ];
- }
-
- $schema = [];
- if (null !== $this->type) {
- $schema['type'] = $this->type;
- }
- if (null !== $this->description) {
- $schema['description'] = $this->description;
- }
- if (null !== $this->enum) {
- $schema['enum'] = $this->enum;
- }
- if (null !== $this->format) {
- $schema['format'] = $this->format;
- }
-
- // String
- if (null !== $this->minLength) {
- $schema['minLength'] = $this->minLength;
- }
- if (null !== $this->maxLength) {
- $schema['maxLength'] = $this->maxLength;
- }
- if (null !== $this->pattern) {
- $schema['pattern'] = $this->pattern;
- }
-
- // Numeric
- if (null !== $this->minimum) {
- $schema['minimum'] = $this->minimum;
- }
- if (null !== $this->maximum) {
- $schema['maximum'] = $this->maximum;
- }
- if (null !== $this->exclusiveMinimum) {
- $schema['exclusiveMinimum'] = $this->exclusiveMinimum;
- }
- if (null !== $this->exclusiveMaximum) {
- $schema['exclusiveMaximum'] = $this->exclusiveMaximum;
- }
- if (null !== $this->multipleOf) {
- $schema['multipleOf'] = $this->multipleOf;
- }
-
- // Array
- if (null !== $this->items) {
- $schema['items'] = $this->items;
- }
- if (null !== $this->minItems) {
- $schema['minItems'] = $this->minItems;
- }
- if (null !== $this->maxItems) {
- $schema['maxItems'] = $this->maxItems;
- }
- if (null !== $this->uniqueItems) {
- $schema['uniqueItems'] = $this->uniqueItems;
- }
-
- // Object
- if (null !== $this->properties) {
- $schema['properties'] = $this->properties;
- }
- if (null !== $this->required) {
- $schema['required'] = $this->required;
- }
- if (null !== $this->additionalProperties) {
- $schema['additionalProperties'] = $this->additionalProperties;
- }
-
- return $schema;
- }
-}
diff --git a/src/Capability/Completion/EnumCompletionProvider.php b/src/Capability/Completion/EnumCompletionProvider.php
deleted file mode 100644
index 7864e4e6..00000000
--- a/src/Capability/Completion/EnumCompletionProvider.php
+++ /dev/null
@@ -1,52 +0,0 @@
-
- */
-class EnumCompletionProvider implements ProviderInterface
-{
- /**
- * @var string[]
- */
- private array $values;
-
- /**
- * @param class-string $enumClass
- */
- public function __construct(string $enumClass)
- {
- if (!enum_exists($enumClass)) {
- throw new InvalidArgumentException(\sprintf('Class "%s" is not an enum.', $enumClass));
- }
-
- $this->values = array_map(
- static fn ($case) => isset($case->value) && \is_string($case->value) ? $case->value : $case->name,
- $enumClass::cases()
- );
- }
-
- public function getCompletions(string $currentValue): array
- {
- if (empty($currentValue)) {
- return $this->values;
- }
-
- return array_values(array_filter(
- $this->values,
- static fn (string $value) => str_starts_with($value, $currentValue)
- ));
- }
-}
diff --git a/src/Capability/Completion/ListCompletionProvider.php b/src/Capability/Completion/ListCompletionProvider.php
deleted file mode 100644
index 5d48f4bd..00000000
--- a/src/Capability/Completion/ListCompletionProvider.php
+++ /dev/null
@@ -1,38 +0,0 @@
-
- */
-class ListCompletionProvider implements ProviderInterface
-{
- /**
- * @param string[] $values
- */
- public function __construct(
- private array $values,
- ) {
- }
-
- public function getCompletions(string $currentValue): array
- {
- if (empty($currentValue)) {
- return $this->values;
- }
-
- return array_values(array_filter(
- $this->values,
- static fn (string $value) => str_starts_with($value, $currentValue)
- ));
- }
-}
diff --git a/src/Capability/Completion/ProviderInterface.php b/src/Capability/Completion/ProviderInterface.php
deleted file mode 100644
index 84f3f234..00000000
--- a/src/Capability/Completion/ProviderInterface.php
+++ /dev/null
@@ -1,27 +0,0 @@
-
- */
-interface ProviderInterface
-{
- /**
- * Get completions for a given current value.
- *
- * @param string $currentValue the current value to get completions for
- *
- * @return string[] the completions
- */
- public function getCompletions(string $currentValue): array;
-}
diff --git a/src/Capability/Discovery/CachedDiscoverer.php b/src/Capability/Discovery/CachedDiscoverer.php
deleted file mode 100644
index d5ed8525..00000000
--- a/src/Capability/Discovery/CachedDiscoverer.php
+++ /dev/null
@@ -1,100 +0,0 @@
-
- */
-final class CachedDiscoverer implements DiscovererInterface
-{
- private const CACHE_PREFIX = 'mcp_discovery_';
-
- public function __construct(
- private readonly DiscovererInterface $discoverer,
- private readonly CacheInterface $cache,
- private readonly LoggerInterface $logger,
- ) {
- }
-
- /**
- * Discover MCP elements in the specified directories with caching.
- *
- * @param string $basePath the base path for resolving directories
- * @param array $directories list of directories (relative to base path) to scan
- * @param array $excludeDirs list of directories (relative to base path) to exclude from the scan
- * @param array $namePatterns list of file name patterns for the scan. Compatible with Finder->name()
- */
- public function discover(string $basePath, array $directories, array $excludeDirs = [], array $namePatterns = self::DEFAULT_NAME_PATERNS): DiscoveryState
- {
- $cacheKey = $this->generateCacheKey($basePath, $directories, $excludeDirs);
-
- $cachedResult = $this->cache->get($cacheKey);
- if (null !== $cachedResult) {
- $this->logger->debug('Using cached discovery results', [
- 'cache_key' => $cacheKey,
- 'base_path' => $basePath,
- 'directories' => $directories,
- ]);
-
- return $cachedResult;
- }
-
- $this->logger->debug('Cache miss, performing fresh discovery', [
- 'cache_key' => $cacheKey,
- 'base_path' => $basePath,
- 'directories' => $directories,
- ]);
-
- $discoveryState = $this->discoverer->discover($basePath, $directories, $excludeDirs, $namePatterns);
-
- $this->cache->set($cacheKey, $discoveryState);
-
- return $discoveryState;
- }
-
- /**
- * Generate a cache key based on discovery parameters.
- *
- * @param array $directories
- * @param array $excludeDirs
- */
- private function generateCacheKey(string $basePath, array $directories, array $excludeDirs): string
- {
- $keyData = [
- 'base_path' => $basePath,
- 'directories' => $directories,
- 'exclude_dirs' => $excludeDirs,
- ];
-
- return self::CACHE_PREFIX.md5(serialize($keyData));
- }
-
- /**
- * Clear the discovery cache.
- * Useful for development or when files change.
- */
- public function clearCache(): void
- {
- $this->cache->clear();
- $this->logger->info('Discovery cache cleared');
- }
-}
diff --git a/src/Capability/Discovery/Discoverer.php b/src/Capability/Discovery/Discoverer.php
deleted file mode 100644
index dab6590c..00000000
--- a/src/Capability/Discovery/Discoverer.php
+++ /dev/null
@@ -1,455 +0,0 @@
-
- */
-final class Discoverer implements DiscovererInterface
-{
- public function __construct(
- private readonly LoggerInterface $logger = new NullLogger(),
- private ?DocBlockParser $docBlockParser = null,
- private ?SchemaGeneratorInterface $schemaGenerator = null,
- ) {
- if (!class_exists(Finder::class)) {
- throw new RuntimeException('File-based discovery requires symfony/finder. Run: composer require symfony/finder');
- }
-
- $this->docBlockParser = $docBlockParser ?? new DocBlockParser(logger: $this->logger);
- $this->schemaGenerator = $schemaGenerator ?? new SchemaGenerator($this->docBlockParser);
- }
-
- /**
- * Discover MCP elements in the specified directories and return the discovery state.
- *
- * @param string $basePath the base path for resolving directories
- * @param array $directories list of directories (relative to base path) to scan
- * @param array $excludeDirs list of directories (relative to base path) to exclude from the scan
- * @param array $namePatterns list of file name patterns for the scan. Compatible with Finder->name()
- */
- public function discover(string $basePath, array $directories, array $excludeDirs = [], array $namePatterns = self::DEFAULT_NAME_PATERNS): DiscoveryState
- {
- $startTime = microtime(true);
- $discoveredCount = [
- 'tools' => 0,
- 'resources' => 0,
- 'prompts' => 0,
- 'resourceTemplates' => 0,
- ];
-
- $namePatterns = !empty($namePatterns) ? $namePatterns : self::DEFAULT_NAME_PATERNS;
-
- $tools = [];
- $resources = [];
- $prompts = [];
- $resourceTemplates = [];
-
- try {
- $finder = new Finder();
- $absolutePaths = [];
- foreach ($directories as $dir) {
- $path = rtrim($basePath, '/').'/'.ltrim($dir, '/');
- if (is_dir($path)) {
- $absolutePaths[] = $path;
- }
- }
-
- if (empty($absolutePaths)) {
- $this->logger->warning('No valid discovery directories found to scan.', [
- 'configured_paths' => $directories,
- 'base_path' => $basePath,
- ]);
-
- return new DiscoveryState();
- }
-
- $finder->files()
- ->in($absolutePaths)
- ->exclude($excludeDirs)
- ->name($namePatterns);
-
- foreach ($finder as $file) {
- $this->processFile($file, $discoveredCount, $tools, $resources, $prompts, $resourceTemplates);
- }
- } catch (\Throwable $e) {
- $this->logger->error('Error during file finding process for MCP discovery'.json_encode($e->getTrace(), \JSON_PRETTY_PRINT), [
- 'exception' => $e,
- ]);
- }
-
- $duration = microtime(true) - $startTime;
- $this->logger->info('Attribute discovery finished.', [
- 'duration_sec' => round($duration, 3),
- 'tools' => $discoveredCount['tools'],
- 'resources' => $discoveredCount['resources'],
- 'prompts' => $discoveredCount['prompts'],
- 'resourceTemplates' => $discoveredCount['resourceTemplates'],
- ]);
-
- return new DiscoveryState($tools, $resources, $prompts, $resourceTemplates);
- }
-
- /**
- * Process a single PHP file for MCP elements on classes or methods.
- *
- * @param DiscoveredCount $discoveredCount
- * @param array $tools
- * @param array $resources
- * @param array $prompts
- * @param array $resourceTemplates
- */
- private function processFile(SplFileInfo $file, array &$discoveredCount, array &$tools, array &$resources, array &$prompts, array &$resourceTemplates): void
- {
- $className = $this->getClassFromFile($file);
- if (!$className) {
- $this->logger->warning('No valid class found in file', ['file' => $file->getPathname()]);
-
- return;
- }
-
- try {
- $reflectionClass = new \ReflectionClass($className);
-
- if ($reflectionClass->isAbstract() || $reflectionClass->isInterface() || $reflectionClass->isTrait() || $reflectionClass->isEnum()) {
- return;
- }
-
- $processedViaClassAttribute = false;
- if ($reflectionClass->hasMethod('__invoke')) {
- $invokeMethod = $reflectionClass->getMethod('__invoke');
- if ($invokeMethod->isPublic() && !$invokeMethod->isStatic()) {
- $attributeTypes = [McpTool::class, McpResource::class, McpPrompt::class, McpResourceTemplate::class];
- foreach ($attributeTypes as $attributeType) {
- $classAttribute = $reflectionClass->getAttributes($attributeType, \ReflectionAttribute::IS_INSTANCEOF)[0] ?? null;
- if ($classAttribute) {
- $this->processMethod($invokeMethod, $discoveredCount, $classAttribute, $tools, $resources, $prompts, $resourceTemplates);
- $processedViaClassAttribute = true;
- break;
- }
- }
- }
- }
-
- if (!$processedViaClassAttribute) {
- foreach ($reflectionClass->getMethods(\ReflectionMethod::IS_PUBLIC) as $method) {
- if (
- $method->getDeclaringClass()->getName() !== $reflectionClass->getName()
- || $method->isStatic() || $method->isAbstract() || $method->isConstructor() || $method->isDestructor() || '__invoke' === $method->getName()
- ) {
- continue;
- }
- $attributeTypes = [McpTool::class, McpResource::class, McpPrompt::class, McpResourceTemplate::class];
- foreach ($attributeTypes as $attributeType) {
- $methodAttribute = $method->getAttributes($attributeType, \ReflectionAttribute::IS_INSTANCEOF)[0] ?? null;
- if ($methodAttribute) {
- $this->processMethod($method, $discoveredCount, $methodAttribute, $tools, $resources, $prompts, $resourceTemplates);
- break;
- }
- }
- }
- }
- } catch (\ReflectionException $e) {
- $this->logger->error('Reflection error processing file for MCP discovery', ['file' => $file->getPathname(), 'class' => $className, 'exception' => $e]);
- } catch (\Throwable $e) {
- $this->logger->error('Unexpected error processing file for MCP discovery', [
- 'file' => $file->getPathname(),
- 'class' => $className,
- 'exception' => $e,
- ]);
- }
- }
-
- /**
- * Process a method with a given MCP attribute instance.
- * Can be called for regular methods or the __invoke method of an invokable class.
- *
- * @param \ReflectionMethod $method The target method (e.g., regular method or __invoke).
- * @param DiscoveredCount $discoveredCount pass by reference to update counts
- * @param \ReflectionAttribute $attribute the ReflectionAttribute instance found (on method or class)
- * @param array $tools
- * @param array $resources
- * @param array $prompts
- * @param array $resourceTemplates
- */
- private function processMethod(\ReflectionMethod $method, array &$discoveredCount, \ReflectionAttribute $attribute, array &$tools, array &$resources, array &$prompts, array &$resourceTemplates): void
- {
- $className = $method->getDeclaringClass()->getName();
- $classShortName = $method->getDeclaringClass()->getShortName();
- $methodName = $method->getName();
- $attributeClassName = $attribute->getName();
-
- try {
- $instance = $attribute->newInstance();
-
- switch ($attributeClassName) {
- case McpTool::class:
- $docBlock = $this->docBlockParser->parseDocBlock($method->getDocComment() ?? null);
- $name = $instance->name ?? ('__invoke' === $methodName ? $classShortName : $methodName);
- $description = $instance->description ?? $this->docBlockParser->getDescription($docBlock) ?? null;
- $inputSchema = $this->schemaGenerator->generate($method);
- $outputSchema = $this->schemaGenerator->generateOutputSchema($method);
- $tool = new Tool(
- name: $name,
- title: $instance->title,
- inputSchema: $inputSchema,
- description: $description,
- annotations: $instance->annotations,
- icons: $instance->icons,
- meta: $instance->meta,
- outputSchema: $outputSchema,
- );
- $tools[$name] = new ToolReference($tool, [$className, $methodName]);
- ++$discoveredCount['tools'];
- break;
-
- case McpResource::class:
- $docBlock = $this->docBlockParser->parseDocBlock($method->getDocComment() ?? null);
- $name = $instance->name ?? ('__invoke' === $methodName ? $classShortName : $methodName);
- $description = $instance->description ?? $this->docBlockParser->getDescription($docBlock) ?? null;
- $resource = new ResourceDefinition(
- $instance->uri,
- $name,
- $instance->title,
- $description,
- $instance->mimeType,
- $instance->annotations,
- $instance->size,
- $instance->icons,
- $instance->meta,
- );
- $resources[$instance->uri] = new ResourceReference($resource, [$className, $methodName]);
-
- ++$discoveredCount['resources'];
- break;
-
- case McpPrompt::class:
- $docBlock = $this->docBlockParser->parseDocBlock($method->getDocComment() ?? null);
- $name = $instance->name ?? ('__invoke' === $methodName ? $classShortName : $methodName);
- $description = $instance->description ?? $this->docBlockParser->getDescription($docBlock) ?? null;
- $arguments = [];
- $paramTags = $this->docBlockParser->getParamTags($docBlock);
- foreach ($method->getParameters() as $param) {
- $reflectionType = $param->getType();
- if ($reflectionType instanceof \ReflectionNamedType && !$reflectionType->isBuiltin()) {
- continue;
- }
- $paramTag = $paramTags['$'.$param->getName()] ?? null;
- $arguments[] = new PromptArgument($param->getName(), $paramTag ? trim((string) $paramTag->getDescription()) : null, !$param->isOptional() && !$param->isDefaultValueAvailable());
- }
- $prompt = new Prompt($name, $instance->title, $description, $arguments, $instance->icons, $instance->meta);
- $completionProviders = $this->getCompletionProviders($method);
- $prompts[$name] = new PromptReference($prompt, [$className, $methodName], $completionProviders);
- ++$discoveredCount['prompts'];
- break;
-
- case McpResourceTemplate::class:
- $docBlock = $this->docBlockParser->parseDocBlock($method->getDocComment() ?? null);
- $name = $instance->name ?? ('__invoke' === $methodName ? $classShortName : $methodName);
- $description = $instance->description ?? $this->docBlockParser->getDescription($docBlock) ?? null;
- $mimeType = $instance->mimeType;
- $annotations = $instance->annotations;
- $meta = $instance->meta ?? null;
- $resourceTemplate = new ResourceTemplate($instance->uriTemplate, $name, $instance->title, $description, $mimeType, $annotations, $meta);
- $completionProviders = $this->getCompletionProviders($method);
- $resourceTemplates[$instance->uriTemplate] = new ResourceTemplateReference($resourceTemplate, [$className, $methodName], $completionProviders);
- ++$discoveredCount['resourceTemplates'];
- break;
- }
- } catch (ExceptionInterface $e) {
- $this->logger->error("Failed to process MCP attribute on {$className}::{$methodName}", [
- 'attribute' => $attributeClassName,
- 'exception' => $e,
- ]);
- } catch (\Throwable $e) {
- $this->logger->error("Unexpected error processing attribute on {$className}::{$methodName}", [
- 'attribute' => $attributeClassName,
- 'exception' => $e,
- ]);
- }
- }
-
- /**
- * @return array
- */
- private function getCompletionProviders(\ReflectionMethod $reflectionMethod): array
- {
- $completionProviders = [];
- foreach ($reflectionMethod->getParameters() as $param) {
- $reflectionType = $param->getType();
- if ($reflectionType instanceof \ReflectionNamedType && !$reflectionType->isBuiltin()) {
- continue;
- }
-
- $completionAttributes = $param->getAttributes(CompletionProvider::class, \ReflectionAttribute::IS_INSTANCEOF);
- if (!empty($completionAttributes)) {
- $attributeInstance = $completionAttributes[0]->newInstance();
-
- if ($attributeInstance->provider) {
- $completionProviders[$param->getName()] = $attributeInstance->provider;
- } elseif ($attributeInstance->providerClass) {
- $completionProviders[$param->getName()] = $attributeInstance->provider;
- } elseif ($attributeInstance->values) {
- $completionProviders[$param->getName()] = new ListCompletionProvider($attributeInstance->values);
- } elseif ($attributeInstance->enum) {
- $completionProviders[$param->getName()] = new EnumCompletionProvider($attributeInstance->enum);
- }
- }
- }
-
- return $completionProviders;
- }
-
- /**
- * Attempt to determine the FQCN from a PHP file path.
- * Uses tokenization to extract namespace and class name.
- *
- * @return class-string|null the FQCN or null if not found/determinable
- */
- private function getClassFromFile(SplFileInfo $file): ?string
- {
- $this->logger->debug('Processing file', ['path' => $file->getPathname()]);
-
- try {
- $content = $file->getContents();
- } catch (\Throwable $e) {
- $this->logger->warning("Failed to read file content during class discovery: {$file->getPathname()}", [
- 'exception' => $e,
- ]);
-
- return null;
- }
-
- if (\strlen($content) > 500 * 1024) {
- $this->logger->warning('Skipping large file during class discovery.', ['file' => $file->getPathname()]);
-
- return null;
- }
-
- try {
- $tokens = token_get_all($content);
- } catch (\Throwable $e) {
- $this->logger->warning("Failed to tokenize file during class discovery: {$file->getPathname()}", [
- 'exception' => $e,
- ]);
-
- return null;
- }
-
- $namespace = '';
- $namespaceFound = false;
- $level = 0;
- $potentialClasses = [];
-
- $tokenCount = \count($tokens);
- for ($i = 0; $i < $tokenCount; ++$i) {
- if (\is_array($tokens[$i]) && \T_NAMESPACE === $tokens[$i][0]) {
- $namespace = '';
- for ($j = $i + 1; $j < $tokenCount; ++$j) {
- if (';' === $tokens[$j] || '{' === $tokens[$j]) {
- $namespaceFound = true;
- $i = $j;
- break;
- }
- if (\is_array($tokens[$j]) && \in_array($tokens[$j][0], [\T_STRING, \T_NAME_QUALIFIED])) {
- $namespace .= $tokens[$j][1];
- } elseif (\T_NS_SEPARATOR === $tokens[$j][0]) {
- $namespace .= '\\';
- }
- }
- if ($namespaceFound) {
- break;
- }
- }
- }
- $namespace = trim($namespace, '\\');
-
- for ($i = 0; $i < $tokenCount; ++$i) {
- $token = $tokens[$i];
- if ('{' === $token) {
- ++$level;
-
- continue;
- }
- if ('}' === $token) {
- --$level;
-
- continue;
- }
-
- if ($level === ($namespaceFound && str_contains($content, "namespace {$namespace} {") ? 1 : 0)) {
- if (\is_array($token) && \in_array($token[0], [\T_CLASS, \T_INTERFACE, \T_TRAIT, \defined('T_ENUM') ? \T_ENUM : -1])) {
- for ($j = $i + 1; $j < $tokenCount; ++$j) {
- if (\is_array($tokens[$j]) && \T_STRING === $tokens[$j][0]) {
- $className = $tokens[$j][1];
- $potentialClasses[] = $namespace ? $namespace.'\\'.$className : $className;
- $i = $j;
- break;
- }
- if (';' === $tokens[$j] || '{' === $tokens[$j] || ')' === $tokens[$j]) {
- break;
- }
- }
- }
- }
- }
-
- foreach ($potentialClasses as $potentialClass) {
- if (class_exists($potentialClass, true)) {
- return $potentialClass;
- }
- }
-
- if (!empty($potentialClasses)) {
- if (!class_exists($potentialClasses[0], false)) {
- $this->logger->debug('getClassFromFile returning potential non-class type. Are you sure this class has been autoloaded?', ['file' => $file->getPathname(), 'type' => $potentialClasses[0]]);
- }
-
- return $potentialClasses[0];
- }
-
- return null;
- }
-}
diff --git a/src/Capability/Discovery/DiscovererInterface.php b/src/Capability/Discovery/DiscovererInterface.php
deleted file mode 100644
index 2df69f35..00000000
--- a/src/Capability/Discovery/DiscovererInterface.php
+++ /dev/null
@@ -1,34 +0,0 @@
-
- */
-interface DiscovererInterface
-{
- public const DEFAULT_NAME_PATERNS = ['*.php'];
-
- /**
- * Discover MCP elements in the specified directories and return the discovery state.
- *
- * @param string $basePath the base path for resolving directories
- * @param array $directories list of directories (relative to base path) to scan
- * @param array $excludeDirs list of directories (relative to base path) to exclude from the scan
- * @param array $namePatterns list of file name patterns for the scan. Compatible with Finder->name()
- */
- public function discover(string $basePath, array $directories, array $excludeDirs = [], array $namePatterns = self::DEFAULT_NAME_PATERNS): DiscoveryState;
-}
diff --git a/src/Capability/Discovery/DiscoveryState.php b/src/Capability/Discovery/DiscoveryState.php
deleted file mode 100644
index 59a73ec8..00000000
--- a/src/Capability/Discovery/DiscoveryState.php
+++ /dev/null
@@ -1,128 +0,0 @@
-
- */
-final class DiscoveryState
-{
- /**
- * @param array $tools
- * @param array $resources
- * @param array $prompts
- * @param array $resourceTemplates
- */
- public function __construct(
- private readonly array $tools = [],
- private readonly array $resources = [],
- private readonly array $prompts = [],
- private readonly array $resourceTemplates = [],
- ) {
- }
-
- /**
- * @return array
- */
- public function getTools(): array
- {
- return $this->tools;
- }
-
- /**
- * @return array
- */
- public function getResources(): array
- {
- return $this->resources;
- }
-
- /**
- * @return array
- */
- public function getPrompts(): array
- {
- return $this->prompts;
- }
-
- /**
- * @return array
- */
- public function getResourceTemplates(): array
- {
- return $this->resourceTemplates;
- }
-
- /**
- * Returns the subset of this state whose keys are absent from $next.
- *
- * Asymmetric by design: entries whose keys exist in both states are excluded
- * regardless of value. Used to identify owned entries that a fresh discovery
- * no longer produces.
- */
- public function obsoletedBy(self $next): self
- {
- return new self(
- array_diff_key($this->tools, $next->tools),
- array_diff_key($this->resources, $next->resources),
- array_diff_key($this->prompts, $next->prompts),
- array_diff_key($this->resourceTemplates, $next->resourceTemplates),
- );
- }
-
- /**
- * Check if this state contains any discovered elements.
- */
- public function isEmpty(): bool
- {
- return empty($this->tools)
- && empty($this->resources)
- && empty($this->prompts)
- && empty($this->resourceTemplates);
- }
-
- /**
- * Get the total count of discovered elements.
- */
- public function getElementCount(): int
- {
- return \count($this->tools)
- + \count($this->resources)
- + \count($this->prompts)
- + \count($this->resourceTemplates);
- }
-
- /**
- * Get a breakdown of discovered elements by type.
- *
- * @return array{tools: int, resources: int, prompts: int, resourceTemplates: int}
- */
- public function getElementCounts(): array
- {
- return [
- 'tools' => \count($this->tools),
- 'resources' => \count($this->resources),
- 'prompts' => \count($this->prompts),
- 'resourceTemplates' => \count($this->resourceTemplates),
- ];
- }
-}
diff --git a/src/Capability/Discovery/DocBlockParser.php b/src/Capability/Discovery/DocBlockParser.php
deleted file mode 100644
index eb14a147..00000000
--- a/src/Capability/Discovery/DocBlockParser.php
+++ /dev/null
@@ -1,125 +0,0 @@
-
- */
-class DocBlockParser
-{
- private DocBlockFactoryInterface $docBlockFactory;
-
- public function __construct(
- ?DocBlockFactoryInterface $docBlockFactory = null,
- private readonly LoggerInterface $logger = new NullLogger(),
- ) {
- $this->docBlockFactory = $docBlockFactory ?? DocBlockFactory::createInstance();
- }
-
- /**
- * Safely parses a DocComment string into a DocBlock object.
- */
- public function parseDocBlock(string|false|null $docComment): ?DocBlock
- {
- if (false === $docComment || null === $docComment || empty($docComment)) {
- return null;
- }
- try {
- return $this->docBlockFactory->create($docComment);
- } catch (\Throwable $e) {
- // Log error or handle gracefully if invalid DocBlock syntax is encountered
- $this->logger->warning('Failed to parse DocBlock', [
- 'exception' => $e,
- ]);
-
- return null;
- }
- }
-
- /**
- * Gets the description from a DocBlock (summary + description body).
- */
- public function getDescription(?DocBlock $docBlock): ?string
- {
- if (!$docBlock) {
- return null;
- }
- $summary = trim($docBlock->getSummary());
- $descriptionBody = trim((string) $docBlock->getDescription());
-
- if ($summary && $descriptionBody) {
- return $summary."\n\n".$descriptionBody;
- }
- if ($summary) {
- return $summary;
- }
- if ($descriptionBody) {
- return $descriptionBody;
- }
-
- return null;
- }
-
- /**
- * Extracts "@param" tag information from a DocBlock, keyed by variable name (e.g., '$paramName').
- *
- * @return array
- */
- public function getParamTags(?DocBlock $docBlock): array
- {
- if (!$docBlock) {
- return [];
- }
-
- /** @var array $paramTags */
- $paramTags = [];
- foreach ($docBlock->getTagsByName('param') as $tag) {
- if ($tag instanceof Param && $tag->getVariableName()) {
- $paramTags['$'.$tag->getVariableName()] = $tag;
- }
- }
-
- return $paramTags;
- }
-
- /**
- * Gets the description string from a Param tag.
- */
- public function getParamDescription(?Param $paramTag): ?string
- {
- return $paramTag ? (trim((string) $paramTag->getDescription()) ?: null) : null;
- }
-
- /**
- * Gets the type string from a Param tag.
- */
- public function getParamTypeString(?Param $paramTag): ?string
- {
- if ($paramTag && $paramTag->getType()) {
- $typeFromTag = trim((string) $paramTag->getType());
- if (!empty($typeFromTag)) {
- return ltrim($typeFromTag, '\\');
- }
- }
-
- return null;
- }
-}
diff --git a/src/Capability/Discovery/HandlerResolver.php b/src/Capability/Discovery/HandlerResolver.php
deleted file mode 100644
index 8cadcd3c..00000000
--- a/src/Capability/Discovery/HandlerResolver.php
+++ /dev/null
@@ -1,96 +0,0 @@
-
- */
-class HandlerResolver
-{
- /**
- * Validates and resolves a handler to a ReflectionMethod or ReflectionFunction instance.
- *
- * A handler can be:
- * - A Closure: function() { ... }
- * - An array: [ClassName::class, 'methodName'] (resolved on a new or container-provided instance)
- * - An array: [$instance, 'methodName'] (method on a pre-built object instance)
- * - An array: [ClassName::class, 'staticMethod'] (static method, if callable)
- * - A string: InvokableClassName::class (which will resolve to its '__invoke' method)
- *
- * @param Handler $handler the handler to resolve
- *
- * @throws InvalidArgumentException If the handler format is invalid, the class/method doesn't exist,
- * or the method is unsuitable (e.g., private, abstract).
- */
- public static function resolve(\Closure|array|string $handler): \ReflectionMethod|\ReflectionFunction
- {
- if ($handler instanceof \Closure) {
- return new \ReflectionFunction($handler);
- }
-
- if (\is_array($handler)) {
- // A Closure in slot 0 must fall through to the format error rather than
- // be treated as an instance, where "$closure::class" would yield the
- // misleading class name "Closure".
- $target = $handler[0] ?? null;
- $hasValidTarget = (\is_string($target) || \is_object($target)) && !$target instanceof \Closure;
-
- if (2 !== \count($handler) || !$hasValidTarget || !isset($handler[1]) || !\is_string($handler[1])) {
- throw new InvalidArgumentException('Invalid array handler format. Expected [ClassName::class, \'methodName\'] or [$instance, \'methodName\'].');
- }
- [$classOrObject, $methodName] = $handler;
- $className = \is_object($classOrObject) ? $classOrObject::class : $classOrObject;
- if (!class_exists($className)) {
- throw new InvalidArgumentException(\sprintf('Handler class "%s" not found for array handler.', $className));
- }
- if (!method_exists($className, $methodName)) {
- throw new InvalidArgumentException(\sprintf('Handler method "%s" not found in class "%s" for array handler.', $methodName, $className));
- }
- } elseif (class_exists($handler)) {
- $className = $handler;
- $methodName = '__invoke';
- if (!method_exists($className, $methodName)) {
- throw new InvalidArgumentException(\sprintf('Invokable handler class "%s" must have a public "__invoke" method.', $className));
- }
- } else {
- throw new InvalidArgumentException('Invalid handler format. Expected Closure, [ClassName::class, \'methodName\'] or InvokableClassName::class string.');
- }
-
- try {
- $reflectionMethod = new \ReflectionMethod($className, $methodName);
-
- // For discovered elements (non-manual), still reject static methods
- // For manual elements, we'll allow static methods since they're callable
- if (!$reflectionMethod->isPublic()) {
- throw new InvalidArgumentException(\sprintf('Handler method "%s::%s" must be public.', $className, $methodName));
- }
- if ($reflectionMethod->isAbstract()) {
- throw new InvalidArgumentException(\sprintf('Handler method "%s::%s" must not be abstract.', $className, $methodName));
- }
- if ($reflectionMethod->isConstructor() || $reflectionMethod->isDestructor()) {
- throw new InvalidArgumentException(\sprintf('Handler method "%s::%s" cannot be a constructor or destructor.', $className, $methodName));
- }
-
- return $reflectionMethod;
- } catch (\ReflectionException $e) {
- // This typically occurs if class_exists passed but ReflectionMethod still fails (rare)
- throw new InvalidArgumentException(\sprintf('Reflection error for handler "%s::%s": %s', $className, $methodName, $e->getMessage()), 0, $e);
- }
- }
-}
diff --git a/src/Capability/Discovery/SchemaGenerator.php b/src/Capability/Discovery/SchemaGenerator.php
deleted file mode 100644
index 80779c70..00000000
--- a/src/Capability/Discovery/SchemaGenerator.php
+++ /dev/null
@@ -1,879 +0,0 @@
-
- * }
- * @phpstan-type InferredParameterSchema array{
- * type?: string|array,
- * description?: string,
- * default?: mixed,
- * enum?: array,
- * items?: array,
- * }
- * @phpstan-type VariadicParameterSchema array{
- * type: 'array',
- * items?: array,
- * description?: string,
- * parameter_schema?: array
- * }
- *
- * @author Kyrian Obikwelu
- */
-final class SchemaGenerator implements SchemaGeneratorInterface
-{
- public function __construct(
- private readonly DocBlockParser $docBlockParser,
- ) {
- }
-
- /**
- * Generates a JSON Schema object (as a PHP array) for parameters.
- *
- * @return array
- */
- public function generate(\Reflector $reflection): array
- {
- if ($reflection instanceof \ReflectionClass) {
- throw new BadMethodCallException('Schema generation from ReflectionClass is not implemented yet. Use ReflectionMethod or ReflectionFunction instead.');
- }
-
- if (!$reflection instanceof \ReflectionMethod && !$reflection instanceof \ReflectionFunction) {
- throw new BadMethodCallException(\sprintf('Schema generation from %s is not supported. Use ReflectionMethod or ReflectionFunction instead.', $reflection::class));
- }
-
- $methodSchema = $this->extractMethodLevelSchema($reflection);
-
- if ($methodSchema && isset($methodSchema['definition'])) {
- return $methodSchema['definition'];
- }
-
- $parametersInfo = $this->parseParametersInfo($reflection);
-
- return $this->buildSchemaFromParameters($parametersInfo, $methodSchema);
- }
-
- /**
- * Generates a JSON Schema object (as a PHP array) for a method's or function's return type.
- *
- * Only returns an outputSchema if explicitly provided in the McpTool attribute.
- * Per MCP spec, outputSchema should only be present when explicitly provided.
- *
- * @return ?array
- */
- public function generateOutputSchema(\Reflector $reflection): ?array
- {
- if ($reflection instanceof \ReflectionClass) {
- throw new BadMethodCallException('Schema generation from ReflectionClass is not implemented yet. Use ReflectionMethod or ReflectionFunction instead.');
- }
-
- if (!$reflection instanceof \ReflectionMethod && !$reflection instanceof \ReflectionFunction) {
- throw new BadMethodCallException(\sprintf('Schema generation from %s is not supported. Use ReflectionMethod or ReflectionFunction instead.', $reflection::class));
- }
-
- // Only return outputSchema if explicitly provided in McpTool attribute
- $mcpToolAttrs = $reflection->getAttributes(McpTool::class, \ReflectionAttribute::IS_INSTANCEOF);
- if ($mcpToolAttrs) {
- $mcpToolInstance = $mcpToolAttrs[0]->newInstance();
-
- return $mcpToolInstance->outputSchema;
- }
-
- return null;
- }
-
- /**
- * Extracts method-level or function-level Schema attribute.
- *
- * @return SchemaAttributeData
- */
- private function extractMethodLevelSchema(\ReflectionFunctionAbstract $reflection): ?array
- {
- $schemaAttrs = $reflection->getAttributes(Schema::class, \ReflectionAttribute::IS_INSTANCEOF);
- if (empty($schemaAttrs)) {
- return null;
- }
-
- /** @var Schema $schemaAttr */
- $schemaAttr = $schemaAttrs[0]->newInstance();
-
- return $schemaAttr->toArray();
- }
-
- /**
- * Extracts parameter-level Schema attribute.
- *
- * @return SchemaAttributeData
- */
- private function extractParameterLevelSchema(\ReflectionParameter $parameter): array
- {
- $schemaAttrs = $parameter->getAttributes(Schema::class, \ReflectionAttribute::IS_INSTANCEOF);
- if (empty($schemaAttrs)) {
- return [];
- }
-
- /** @var Schema $schemaAttr */
- $schemaAttr = $schemaAttrs[0]->newInstance();
-
- return $schemaAttr->toArray();
- }
-
- /**
- * Builds the final schema from parameter information and method-level schema.
- *
- * @param ParameterInfo[] $parametersInfo
- * @param SchemaAttributeData $methodSchema
- *
- * @return array
- */
- private function buildSchemaFromParameters(array $parametersInfo, ?array $methodSchema): array
- {
- $schema = [
- 'type' => 'object',
- 'properties' => [],
- 'required' => [],
- ];
-
- // Apply method-level schema as base
- if ($methodSchema) {
- $schema = array_merge($schema, $methodSchema);
- if (!isset($schema['type'])) {
- $schema['type'] = 'object';
- }
- if (!isset($schema['properties'])) {
- $schema['properties'] = [];
- }
- if (!isset($schema['required'])) {
- $schema['required'] = [];
- }
- }
-
- foreach ($parametersInfo as $paramInfo) {
- $paramName = $paramInfo['name'];
-
- $methodLevelParamSchema = $schema['properties'][$paramName] ?? null;
-
- $paramSchema = $this->buildParameterSchema($paramInfo, $methodLevelParamSchema);
-
- $schema['properties'][$paramName] = $paramSchema;
-
- if ($paramInfo['required'] && !\in_array($paramName, $schema['required'])) {
- $schema['required'][] = $paramName;
- } elseif (!$paramInfo['required'] && ($key = array_search($paramName, $schema['required'])) !== false) {
- unset($schema['required'][$key]);
- $schema['required'] = array_values($schema['required']); // Re-index
- }
- }
-
- // Clean up empty properties
- if (empty($schema['properties'])) {
- $schema['properties'] = new \stdClass();
- }
- if (empty($schema['required'])) {
- unset($schema['required']);
- }
-
- return $schema;
- }
-
- /**
- * Builds the final schema for a single parameter by merging all three levels.
- *
- * @param ParameterInfo $paramInfo
- * @param array|null $methodLevelParamSchema
- */
- private function buildParameterSchema(array $paramInfo, ?array $methodLevelParamSchema): array
- {
- if ($paramInfo['is_variadic']) {
- return $this->ensureArrayItems($this->buildVariadicParameterSchema($paramInfo));
- }
-
- $inferredSchema = $this->buildInferredParameterSchema($paramInfo);
-
- // Method-level takes precedence over inferred schema
- $mergedSchema = $inferredSchema;
- if ($methodLevelParamSchema) {
- $mergedSchema = array_merge($inferredSchema, $methodLevelParamSchema);
- }
-
- // Parameter-level takes highest precedence
- $parameterLevelSchema = $paramInfo['parameter_schema'];
- if (!empty($parameterLevelSchema)) {
- $mergedSchema = array_merge($mergedSchema, $parameterLevelSchema);
- }
-
- // Run after all merges so that when a Schema attribute reshapes the parameter
- // (e.g. to `object`), the array `items` invariant is only enforced on what is
- // genuinely still an array.
- return $this->ensureArrayItems($mergedSchema);
- }
-
- /**
- * Guarantees an array-typed schema always declares `items`.
- *
- * `items` is optional in JSON Schema, but some strict clients reject an array schema
- * without it. When no element type could be inferred, default to the empty schema `{}`
- * (matches anything) — represented as `new \stdClass()` so it serializes to `{}` rather
- * than `[]`.
- *
- * @param array $schema
- *
- * @return array
- */
- private function ensureArrayItems(array $schema): array
- {
- $type = $schema['type'] ?? null;
- $isArray = 'array' === $type || (\is_array($type) && \in_array('array', $type, true));
-
- if ($isArray && !isset($schema['items'])) {
- $schema['items'] = new \stdClass();
- }
-
- return $schema;
- }
-
- /**
- * Builds parameter schema from inferred type and docblock information only.
- * Returns empty array for variadic parameters (handled separately).
- *
- * @param ParameterInfo $paramInfo
- *
- * @return InferredParameterSchema
- */
- private function buildInferredParameterSchema(array $paramInfo): array
- {
- $paramSchema = [];
-
- // Variadic parameters are handled separately
- if ($paramInfo['is_variadic']) {
- return [];
- }
-
- // Infer JSON Schema types
- $jsonTypes = $this->inferParameterTypes($paramInfo);
-
- if (1 === \count($jsonTypes)) {
- $paramSchema['type'] = $jsonTypes[0];
- } elseif (\count($jsonTypes) > 1) {
- $paramSchema['type'] = $jsonTypes;
- }
-
- // Add description from docblock
- if ($paramInfo['description']) {
- $paramSchema['description'] = $paramInfo['description'];
- }
-
- // Add default value only if parameter actually has a default
- if ($paramInfo['has_default']) {
- $paramSchema['default'] = $paramInfo['default_value'];
- }
-
- // Handle enums
- $paramSchema = $this->applyEnumConstraints($paramSchema, $paramInfo);
-
- // Handle array items
- return $this->applyArrayConstraints($paramSchema, $paramInfo);
- }
-
- /**
- * Builds schema for variadic parameters.
- *
- * @param ParameterInfo $paramInfo
- *
- * @return VariadicParameterSchema
- */
- private function buildVariadicParameterSchema(array $paramInfo): array
- {
- $paramSchema = ['type' => 'array'];
-
- // Apply parameter-level Schema attributes first
- if (!empty($paramInfo['parameter_schema'])) {
- $paramSchema = array_merge($paramSchema, $paramInfo['parameter_schema']);
- // Ensure type is always array for variadic
- $paramSchema['type'] = 'array';
- }
-
- if ($paramInfo['description']) {
- $paramSchema['description'] = $paramInfo['description'];
- }
-
- // If no items specified by Schema attribute, infer from type
- if (!isset($paramSchema['items'])) {
- $itemJsonTypes = $this->mapPhpTypeToJsonSchemaType($paramInfo['type_string']);
- $nonNullItemTypes = array_filter($itemJsonTypes, static fn ($t) => 'null' !== $t);
-
- if (1 === \count($nonNullItemTypes)) {
- $paramSchema['items'] = ['type' => $nonNullItemTypes[0]];
- }
- }
-
- return $paramSchema;
- }
-
- /**
- * Infers JSON Schema types for a parameter.
- *
- * @param ParameterInfo $paramInfo
- */
- private function inferParameterTypes(array $paramInfo): array
- {
- $jsonTypes = $this->mapPhpTypeToJsonSchemaType($paramInfo['type_string']);
-
- if ($paramInfo['allows_null'] && 'mixed' !== strtolower($paramInfo['type_string']) && !\in_array('null', $jsonTypes)) {
- $jsonTypes[] = 'null';
- }
-
- if (\count($jsonTypes) > 1) {
- // Sort but ensure null comes first for consistency
- $nullIndex = array_search('null', $jsonTypes);
- if (false !== $nullIndex) {
- unset($jsonTypes[$nullIndex]);
- sort($jsonTypes);
- array_unshift($jsonTypes, 'null');
- } else {
- sort($jsonTypes);
- }
- }
-
- return $jsonTypes;
- }
-
- /**
- * Applies enum constraints to parameter schema.
- */
- private function applyEnumConstraints(array $paramSchema, array $paramInfo): array
- {
- $reflectionType = $paramInfo['reflection_type_object'];
-
- if (!($reflectionType instanceof \ReflectionNamedType) || $reflectionType->isBuiltin() || !enum_exists($reflectionType->getName())) {
- return $paramSchema;
- }
-
- $enumClass = $reflectionType->getName();
- $enumReflection = new \ReflectionEnum($enumClass);
- $backingTypeReflection = $enumReflection->getBackingType();
-
- if ($enumReflection->isBacked() && $backingTypeReflection instanceof \ReflectionNamedType) {
- $paramSchema['enum'] = array_column($enumClass::cases(), 'value');
- $jsonBackingType = match ($backingTypeReflection->getName()) {
- 'int' => 'integer',
- 'string' => 'string',
- default => null,
- };
-
- if ($jsonBackingType) {
- if (isset($paramSchema['type']) && \is_array($paramSchema['type']) && \in_array('null', $paramSchema['type'])) {
- $paramSchema['type'] = [$jsonBackingType, 'null'];
- $paramSchema['enum'][] = null;
- } else {
- $paramSchema['type'] = $jsonBackingType;
- }
- }
- } else {
- // Non-backed enum - use names as enum values
- $paramSchema['enum'] = array_column($enumClass::cases(), 'name');
- if (isset($paramSchema['type']) && \is_array($paramSchema['type']) && \in_array('null', $paramSchema['type'])) {
- $paramSchema['type'] = ['string', 'null'];
- $paramSchema['enum'][] = null;
- } else {
- $paramSchema['type'] = 'string';
- }
- }
-
- return $paramSchema;
- }
-
- /**
- * Applies array-specific constraints to parameter schema.
- */
- private function applyArrayConstraints(array $paramSchema, array $paramInfo): array
- {
- if (!isset($paramSchema['type'])) {
- return $paramSchema;
- }
-
- $typeString = $paramInfo['type_string'];
- $allowsNull = $paramInfo['allows_null'];
-
- // Handle object-like arrays using array{} syntax
- if (preg_match('/^array\s*{/i', $typeString)) {
- $objectSchema = $this->inferArrayItemsType($typeString);
- if (\is_array($objectSchema) && isset($objectSchema['properties'])) {
- $paramSchema = array_merge($paramSchema, $objectSchema);
- $paramSchema['type'] = $allowsNull ? ['object', 'null'] : 'object';
- }
- }
- // Handle regular arrays
- elseif (\in_array('array', $this->mapPhpTypeToJsonSchemaType($typeString))) {
- $itemsType = $this->inferArrayItemsType($typeString);
- if ('any' !== $itemsType) {
- if (\is_string($itemsType)) {
- $paramSchema['items'] = ['type' => $itemsType];
- } else {
- if (!isset($itemsType['type']) && isset($itemsType['properties'])) {
- $itemsType = array_merge(['type' => 'object'], $itemsType);
- }
- $paramSchema['items'] = $itemsType;
- }
- }
-
- if ($allowsNull) {
- $paramSchema['type'] = ['array', 'null'];
- sort($paramSchema['type']);
- } else {
- $paramSchema['type'] = 'array';
- }
- }
-
- return $paramSchema;
- }
-
- /**
- * Parses detailed information about a method's parameters.
- *
- * @return ParameterInfo[]
- */
- private function parseParametersInfo(\ReflectionMethod|\ReflectionFunction $reflection): array
- {
- $docComment = $reflection->getDocComment() ?: null;
- $docBlock = $this->docBlockParser->parseDocBlock($docComment);
- $paramTags = $this->docBlockParser->getParamTags($docBlock);
- $parametersInfo = [];
-
- foreach ($reflection->getParameters() as $rp) {
- $reflectionType = $rp->getType();
-
- if ($reflectionType instanceof \ReflectionNamedType && !$reflectionType->isBuiltin()) {
- $typeName = $reflectionType->getName();
-
- if (is_a($typeName, RequestContext::class, true)) {
- continue;
- }
- }
-
- $paramName = $rp->getName();
- if (\in_array(strtolower($paramName), ['_session', '_request'], true)) {
- throw new InvalidArgumentException(\sprintf('Handler method "%s::%s" has parameter named "%s" which is not allowed. Please change the name of that parameter.', $reflection->class, $reflection->name, $paramName));
- }
- $paramTag = $paramTags['$'.$paramName] ?? null;
-
- $typeString = $this->getParameterTypeString($rp, $paramTag);
- $description = $this->docBlockParser->getParamDescription($paramTag);
- $hasDefault = $rp->isDefaultValueAvailable();
- $defaultValue = $hasDefault ? $rp->getDefaultValue() : null;
- $isVariadic = $rp->isVariadic();
-
- $parameterSchema = $this->extractParameterLevelSchema($rp);
-
- if ($defaultValue instanceof \BackedEnum) {
- $defaultValue = $defaultValue->value;
- }
-
- if ($defaultValue instanceof \UnitEnum) {
- $defaultValue = $defaultValue->name;
- }
-
- $allowsNull = false;
- if ($reflectionType && $reflectionType->allowsNull()) {
- $allowsNull = true;
- } elseif ($hasDefault && null === $defaultValue) {
- $allowsNull = true;
- } elseif (str_contains($typeString, 'null') || 'mixed' === strtolower($typeString)) {
- $allowsNull = true;
- }
-
- $parametersInfo[] = [
- 'name' => $paramName,
- 'doc_block_tag' => $paramTag,
- 'reflection_param' => $rp,
- 'reflection_type_object' => $reflectionType,
- 'type_string' => $typeString,
- 'description' => $description,
- 'required' => !$rp->isOptional(),
- 'allows_null' => $allowsNull,
- 'default_value' => $defaultValue,
- 'has_default' => $hasDefault,
- 'is_variadic' => $isVariadic,
- 'parameter_schema' => $parameterSchema,
- ];
- }
-
- return $parametersInfo;
- }
-
- /**
- * Determines the type string for a parameter, prioritizing DocBlock.
- */
- private function getParameterTypeString(\ReflectionParameter $rp, ?Param $paramTag): string
- {
- $docBlockType = $this->docBlockParser->getParamTypeString($paramTag);
- $isDocBlockTypeGeneric = false;
-
- if (null !== $docBlockType) {
- if (\in_array(strtolower($docBlockType), ['mixed', 'unknown', ''])) {
- $isDocBlockTypeGeneric = true;
- }
- } else {
- $isDocBlockTypeGeneric = true; // No tag or no type in tag implies generic
- }
-
- $reflectionType = $rp->getType();
- $reflectionTypeString = null;
- if ($reflectionType) {
- $reflectionTypeString = $this->getTypeStringFromReflection($reflectionType, $rp->allowsNull());
- }
-
- // Prioritize Reflection if DocBlock type is generic AND Reflection provides a more specific type
- if ($isDocBlockTypeGeneric && null !== $reflectionTypeString && 'mixed' !== $reflectionTypeString) {
- return $reflectionTypeString;
- }
-
- // Otherwise, use the DocBlock type if it was valid and non-generic
- if (null !== $docBlockType && !$isDocBlockTypeGeneric) {
- // Consider if DocBlock adds nullability missing from reflection
- if (false !== stripos($docBlockType, 'null') && $reflectionTypeString && false === stripos($reflectionTypeString, 'null') && !str_ends_with($reflectionTypeString, '|null')) {
- // If reflection didn't capture null, but docblock did, append |null (if not already mixed)
- if ('mixed' !== $reflectionTypeString) {
- return $reflectionTypeString.'|null';
- }
- }
-
- return $docBlockType;
- }
-
- // Fallback to Reflection type even if it was generic ('mixed')
- if (null !== $reflectionTypeString) {
- return $reflectionTypeString;
- }
-
- // Default to 'mixed' if nothing else found
- return 'mixed';
- }
-
- /**
- * Converts a ReflectionType object into a type string representation.
- */
- private function getTypeStringFromReflection(?\ReflectionType $type, bool $nativeAllowsNull): string
- {
- if (null === $type) {
- return 'mixed';
- }
-
- $types = [];
- if ($type instanceof \ReflectionUnionType) {
- foreach ($type->getTypes() as $innerType) {
- $types[] = $this->getTypeStringFromReflection($innerType, $innerType->allowsNull());
- }
- if ($nativeAllowsNull) {
- $types = array_filter($types, static fn ($t) => 'null' !== strtolower($t));
- }
- $typeString = implode('|', array_unique(array_filter($types)));
- } elseif ($type instanceof \ReflectionIntersectionType) {
- foreach ($type->getTypes() as $innerType) {
- $types[] = $this->getTypeStringFromReflection($innerType, false);
- }
- $typeString = implode('&', array_unique(array_filter($types)));
- } elseif ($type instanceof \ReflectionNamedType) {
- $typeString = $type->getName();
- } else {
- return 'mixed';
- }
-
- $typeString = match (strtolower($typeString)) {
- 'bool' => 'boolean',
- 'int' => 'integer',
- 'float', 'double' => 'number',
- 'str' => 'string',
- default => $typeString,
- };
-
- $isNullable = $nativeAllowsNull;
- if ($type instanceof \ReflectionNamedType && 'mixed' === $type->getName()) {
- $isNullable = true;
- }
-
- if ($type instanceof \ReflectionUnionType && !$nativeAllowsNull) {
- foreach ($type->getTypes() as $innerType) {
- if ($innerType instanceof \ReflectionNamedType && 'null' === strtolower($innerType->getName())) {
- $isNullable = true;
- break;
- }
- }
- }
-
- if ($isNullable && 'mixed' !== $typeString && false === stripos($typeString, 'null')) {
- if (!str_ends_with($typeString, '|null') && !str_ends_with($typeString, '&null')) {
- $typeString .= '|null';
- }
- }
-
- // Remove leading backslash from class names, but handle built-ins like 'int' or unions like 'int|string'
- if (str_contains($typeString, '\\')) {
- $parts = preg_split('/([|&])/', $typeString, -1, \PREG_SPLIT_DELIM_CAPTURE);
- $processedParts = array_map(static fn ($part) => str_starts_with($part, '\\') ? ltrim($part, '\\') : $part, $parts);
- $typeString = implode('', $processedParts);
- }
-
- return $typeString ?: 'mixed';
- }
-
- /**
- * Maps a PHP type string (potentially a union) to an array of JSON Schema type names.
- *
- * @return string[]
- */
- private function mapPhpTypeToJsonSchemaType(string $phpTypeString): array
- {
- $normalizedType = strtolower(trim($phpTypeString));
-
- // PRIORITY 1: Check for array{} syntax which should be treated as object
- if (preg_match('/^array\s*{/i', $normalizedType)) {
- return ['object'];
- }
-
- // PRIORITY 2: Check for array syntax first (T[] or generics)
- if (
- str_contains($normalizedType, '[]')
- || preg_match('/^(array|list|iterable|collection)]+\s*>$/i', $normalizedType)) {
- return ['integer'];
- }
-
- // PRIORITY 4: Handle unions (recursive)
- if (str_contains($normalizedType, '|')) {
- $types = explode('|', $normalizedType);
- $jsonTypes = [];
- foreach ($types as $type) {
- $mapped = $this->mapPhpTypeToJsonSchemaType(trim($type));
- $jsonTypes = array_merge($jsonTypes, $mapped);
- }
-
- return array_values(array_unique($jsonTypes));
- }
-
- // PRIORITY 5: Handle simple built-in types
- return match ($normalizedType) {
- 'string', 'scalar' => ['string'],
- '?string' => ['null', 'string'],
- 'int', 'integer' => ['integer'],
- '?int', '?integer' => ['null', 'integer'],
- 'float', 'double', 'number' => ['number'],
- '?float', '?double', '?number' => ['null', 'number'],
- 'bool', 'boolean' => ['boolean'],
- '?bool', '?boolean' => ['null', 'boolean'],
- 'array' => ['array'],
- '?array' => ['null', 'array'],
- 'object', 'stdclass' => ['object'],
- '?object', '?stdclass' => ['null', 'object'],
- 'null' => ['null'],
- 'resource', 'callable' => ['object'],
- 'mixed' => [],
- 'void', 'never' => [],
- default => ['object'],
- };
- }
-
- /**
- * Infers the 'items' schema type for an array based on DocBlock type hints.
- *
- * @return string|array
- */
- private function inferArrayItemsType(string $phpTypeString): string|array
- {
- $normalizedType = trim($phpTypeString);
-
- // Strip a top-level nullable union (e.g. `string[]|null`, `null|int[]`) so the
- // element type is still recovered; array nullability is handled separately via
- // `allows_null`. Internal unions such as `array` are left untouched.
- $normalizedType = trim((string) preg_replace('/^null\s*\|\s*|\s*\|\s*null$/i', '', $normalizedType));
-
- // Case 1: Simple T[] syntax (e.g., string[], int[], bool[], etc.)
- if (preg_match('/^(\\??)([\w\\\\]+)\\s*\\[\\]$/i', $normalizedType, $matches)) {
- $itemType = strtolower($matches[2]);
-
- return $this->mapSimpleTypeToJsonSchema($itemType);
- }
-
- // Case 2: Generic array syntax (e.g., array, array, etc.)
- if (preg_match('/^(\\??)array\s*<\s*([\w\\\\|]+)\s*>$/i', $normalizedType, $matches)) {
- $itemType = strtolower($matches[2]);
-
- return $this->mapSimpleTypeToJsonSchema($itemType);
- }
-
- // Case 3: Nested array> syntax or T[][] syntax
- if (
- preg_match('/^(\\??)array\s*<\s*array\s*<\s*([\w\\\\|]+)\s*>\s*>$/i', $normalizedType, $matches)
- || preg_match('/^(\\??)([\w\\\\]+)\s*\[\]\[\]$/i', $normalizedType, $matches)
- ) {
- $innerType = $this->mapSimpleTypeToJsonSchema(isset($matches[2]) ? strtolower($matches[2]) : 'any'); /* @phpstan-ignore isset.offset */
-
- // Return a schema for array with items being arrays
- return [
- 'type' => 'array',
- 'items' => [
- 'type' => $innerType,
- ],
- ];
- }
-
- // Case 4: Object-like array syntax (e.g., array{name: string, age: int})
- if (preg_match('/^(\\??)array\s*\{(.+)\}$/is', $normalizedType, $matches)) {
- return $this->parseObjectLikeArray($matches[2]);
- }
-
- return 'any';
- }
-
- /**
- * Parses object-like array syntax into a JSON Schema object.
- *
- * @return array{
- * type: 'object',
- * properties?: array,
- * required?: array
- * }
- */
- private function parseObjectLikeArray(string $propertiesStr): array
- {
- $properties = [];
- $required = [];
-
- // Parse properties from the string, handling nested structures
- $depth = 0;
- $buffer = '';
-
- for ($i = 0; $i < \strlen($propertiesStr); ++$i) {
- $char = $propertiesStr[$i];
-
- // Track nested braces
- if ('{' === $char) {
- ++$depth;
- $buffer .= $char;
- } elseif ('}' === $char) {
- --$depth;
- $buffer .= $char;
- }
- // Property separator (comma)
- elseif (',' === $char && 0 === $depth) {
- // Process the completed property
- $this->parsePropertyDefinition(trim($buffer), $properties, $required);
- $buffer = '';
- } else {
- $buffer .= $char;
- }
- }
-
- // Process the last property
- if (!empty($buffer)) {
- $this->parsePropertyDefinition(trim($buffer), $properties, $required);
- }
-
- if (!empty($properties)) {
- return [
- 'type' => 'object',
- 'properties' => $properties,
- 'required' => $required,
- ];
- }
-
- return ['type' => 'object'];
- }
-
- /**
- * Parses a single property definition from an object-like array syntax.
- */
- private function parsePropertyDefinition(string $propDefinition, array &$properties, array &$required): void
- {
- // Match property name and type
- if (preg_match('/^([a-zA-Z_\x80-\xff][a-zA-Z0-9_\x80-\xff]*)\s*:\s*(.+)$/i', $propDefinition, $matches)) {
- $propName = $matches[1];
- $propType = trim($matches[2]);
-
- // Add to required properties
- $required[] = $propName;
-
- // Check for nested array{} syntax
- if (preg_match('/^array\s*\{(.+)\}$/is', $propType, $nestedMatches)) {
- $nestedSchema = $this->parseObjectLikeArray($nestedMatches[1]);
- $properties[$propName] = $nestedSchema;
- }
- // Check for array or T[] syntax
- elseif (
- preg_match('/^array\s*<\s*([\w\\\\|]+)\s*>$/i', $propType, $arrayMatches)
- || preg_match('/^([\w\\\\]+)\s*\[\]$/i', $propType, $arrayMatches)
- ) {
- $itemType = $arrayMatches[1] ?? 'any'; /* @phpstan-ignore nullCoalesce.offset */
- $properties[$propName] = [
- 'type' => 'array',
- 'items' => [
- 'type' => $this->mapSimpleTypeToJsonSchema($itemType),
- ],
- ];
- }
- // Simple type
- else {
- $properties[$propName] = ['type' => $this->mapSimpleTypeToJsonSchema($propType)];
- }
- }
- }
-
- /**
- * Helper method to map basic PHP types to JSON Schema types.
- */
- private function mapSimpleTypeToJsonSchema(string $type): string
- {
- return match (strtolower($type)) {
- 'string' => 'string',
- 'int', 'integer' => 'integer',
- 'bool', 'boolean' => 'boolean',
- 'float', 'double', 'number' => 'number',
- 'array' => 'array',
- 'object', 'stdclass' => 'object',
- default => \in_array(strtolower($type), ['datetime', 'datetimeinterface']) ? 'string' : 'object',
- };
- }
-}
diff --git a/src/Capability/Discovery/SchemaGeneratorInterface.php b/src/Capability/Discovery/SchemaGeneratorInterface.php
deleted file mode 100644
index c21d3cdd..00000000
--- a/src/Capability/Discovery/SchemaGeneratorInterface.php
+++ /dev/null
@@ -1,41 +0,0 @@
-
- */
-interface SchemaGeneratorInterface
-{
- /**
- * Generates a JSON Schema for input parameters.
- *
- * The returned schema must be a valid JSON Schema object (type: 'object')
- * with properties corresponding to a tool's parameters.
- *
- * @return array{
- * type: 'object',
- * properties: array|object,
- * required?: string[]
- * }
- */
- public function generate(\Reflector $reflection): array;
-
- /**
- * Generates a JSON Schema for output/result.
- *
- * @return ?array
- */
- public function generateOutputSchema(\Reflector $reflection): ?array;
-}
diff --git a/src/Capability/Discovery/SchemaValidator.php b/src/Capability/Discovery/SchemaValidator.php
deleted file mode 100644
index 56174bdc..00000000
--- a/src/Capability/Discovery/SchemaValidator.php
+++ /dev/null
@@ -1,335 +0,0 @@
-
- */
-class SchemaValidator
-{
- private ?Validator $jsonSchemaValidator = null;
-
- public function __construct(
- private LoggerInterface $logger = new NullLogger(),
- ) {
- }
-
- /**
- * Validates data against a JSON schema.
- *
- * @param mixed $data the data to validate (should generally be decoded JSON)
- * @param array|object $schema the JSON Schema definition (as PHP array or object)
- *
- * @return list array of validation errors, empty if valid
- */
- public function validateAgainstJsonSchema(mixed $data, array|object $schema): array
- {
- if (\is_array($data) && empty($data)) {
- $data = new \stdClass();
- }
-
- try {
- // --- Schema Preparation ---
- if (\is_array($schema)) {
- $schemaJson = json_encode($schema, \JSON_THROW_ON_ERROR | \JSON_UNESCAPED_SLASHES);
- $schemaObject = json_decode($schemaJson, false, 512, \JSON_THROW_ON_ERROR);
- } elseif (\is_object($schema)) {
- // This might be overly cautious but safer against varied inputs.
- $schemaJson = json_encode($schema, \JSON_THROW_ON_ERROR | \JSON_UNESCAPED_SLASHES);
- $schemaObject = json_decode($schemaJson, false, 512, \JSON_THROW_ON_ERROR);
- } else {
- throw new InvalidArgumentException('Schema must be an array or object.');
- }
-
- // --- Data Preparation ---
- // Opis Validator generally prefers objects for object validation
- $dataToValidate = $this->convertDataForValidator($data);
- } catch (\JsonException $e) {
- $this->logger->error('MCP SDK: Invalid schema structure provided for validation (JSON conversion failed).', ['exception' => $e]);
-
- return [['pointer' => '', 'keyword' => 'internal', 'message' => 'Invalid schema definition provided (JSON error).']];
- } catch (InvalidArgumentException $e) {
- $this->logger->error('MCP SDK: Invalid schema structure provided for validation.', ['exception' => $e]);
-
- return [['pointer' => '', 'keyword' => 'internal', 'message' => $e->getMessage()]];
- } catch (\Throwable $e) {
- $this->logger->error('MCP SDK: Error preparing data/schema for validation.', ['exception' => $e]);
-
- return [['pointer' => '', 'keyword' => 'internal', 'message' => 'Internal validation preparation error.']];
- }
-
- $validator = $this->getJsonSchemaValidator();
-
- try {
- $result = $validator->validate($dataToValidate, $schemaObject);
- } catch (\Throwable $e) {
- $this->logger->error('MCP SDK: JSON Schema validation failed internally.', [
- 'exception' => $e,
- 'data' => json_encode($dataToValidate),
- 'schema' => json_encode($schemaObject),
- ]);
-
- return [['pointer' => '', 'keyword' => 'internal', 'message' => 'Schema validation process failed: '.$e->getMessage()]];
- }
-
- if ($result->isValid()) {
- return [];
- }
-
- $formattedErrors = [];
- $topError = $result->error();
-
- if ($topError) {
- $this->collectSubErrors($topError, $formattedErrors);
- }
-
- if (empty($formattedErrors) && $topError) { // Fallback
- $formattedErrors[] = [
- 'pointer' => $this->formatJsonPointerPath($topError->data()->path()),
- 'keyword' => $topError->keyword(),
- 'message' => $this->formatValidationError($topError),
- ];
- }
-
- return $formattedErrors;
- }
-
- /**
- * Get or create the JSON Schema validator instance.
- */
- private function getJsonSchemaValidator(): Validator
- {
- if (null === $this->jsonSchemaValidator) {
- $this->jsonSchemaValidator = new Validator();
- // Potentially configure resolver here if needed later
- }
-
- return $this->jsonSchemaValidator;
- }
-
- /**
- * Recursively converts associative arrays to stdClass objects for validator compatibility.
- */
- private function convertDataForValidator(mixed $data): mixed
- {
- if (\is_array($data)) {
- // Check if it's an associative array (keys are not sequential numbers 0..N-1)
- if (!empty($data) && array_keys($data) !== range(0, \count($data) - 1)) {
- $obj = new \stdClass();
- foreach ($data as $key => $value) {
- $obj->{$key} = $this->convertDataForValidator($value);
- }
-
- return $obj;
- }
-
- // It's a list (sequential array), convert items recursively
- return array_map([$this, 'convertDataForValidator'], $data);
- } elseif (\is_object($data) && $data instanceof \stdClass) {
- // Deep copy/convert stdClass objects as well
- $obj = new \stdClass();
- foreach (get_object_vars($data) as $key => $value) {
- $obj->{$key} = $this->convertDataForValidator($value);
- }
-
- return $obj;
- }
-
- // Leave other objects and scalar types as they are
- return $data;
- }
-
- /**
- * Recursively collects leaf validation errors.
- *
- * @param Error[] $collectedErrors
- */
- private function collectSubErrors(ValidationError $error, array &$collectedErrors): void
- {
- $subErrors = $error->subErrors();
- if (empty($subErrors)) {
- $collectedErrors[] = [
- 'pointer' => $this->formatJsonPointerPath($error->data()->path()),
- 'keyword' => $error->keyword(),
- 'message' => $this->formatValidationError($error),
- ];
- } else {
- foreach ($subErrors as $subError) {
- $this->collectSubErrors($subError, $collectedErrors);
- }
- }
- }
-
- /**
- * Formats the path array into a JSON Pointer string.
- *
- * @param string[]|int[]|null $pathComponents
- */
- private function formatJsonPointerPath(?array $pathComponents): string
- {
- if (empty($pathComponents)) {
- return '/';
- }
- $escapedComponents = array_map(static function ($component) {
- $componentStr = (string) $component;
-
- return str_replace(['~', '/'], ['~0', '~1'], $componentStr);
- }, $pathComponents);
-
- return '/'.implode('/', $escapedComponents);
- }
-
- /**
- * Formats an Opis SchemaValidationError into a user-friendly message.
- */
- private function formatValidationError(ValidationError $error): string
- {
- $keyword = $error->keyword();
- $args = $error->args();
- $message = "Constraint `{$keyword}` failed.";
-
- switch (strtolower($keyword)) {
- case 'required':
- $missing = $args['missing'] ?? [];
- $formattedMissing = implode(', ', array_map(static fn ($p) => "`{$p}`", $missing));
- $message = "Missing required properties: {$formattedMissing}.";
- break;
- case 'type':
- $expected = implode('|', (array) ($args['expected'] ?? []));
- $used = $error->data()->type() ?? 'unknown';
- $message = "Invalid type. Expected `{$expected}`, but received `{$used}`.";
- break;
- case 'enum':
- $schemaData = $error->schema()->info()->data();
- $allowedValues = [];
- if (\is_object($schemaData) && property_exists($schemaData, 'enum') && \is_array($schemaData->enum)) {
- $allowedValues = $schemaData->enum;
- } elseif (\is_array($schemaData) && isset($schemaData['enum']) && \is_array($schemaData['enum'])) {
- $allowedValues = $schemaData['enum'];
- } else {
- $this->logger->warning("MCP SDK: Could not retrieve 'enum' values from schema info for error.", ['error_args' => $args]);
- }
- if (empty($allowedValues)) {
- $message = 'Value does not match the allowed enumeration.';
- } else {
- $formattedAllowed = array_map(static function ($v) { /* ... formatting logic ... */
- if (\is_string($v)) {
- return '"'.$v.'"';
- }
- if (\is_bool($v)) {
- return $v ? 'true' : 'false';
- }
- if (null === $v) {
- return 'null';
- }
-
- return (string) $v;
- }, $allowedValues);
- $message = 'Value must be one of the allowed values: '.implode(', ', $formattedAllowed).'.';
- }
- break;
- case 'const':
- $expected = json_encode($args['expected'] ?? 'null', \JSON_UNESCAPED_SLASHES | \JSON_UNESCAPED_UNICODE);
- $message = "Value must be equal to the constant value: {$expected}.";
- break;
- case 'minLength': // Corrected casing
- $min = $args['min'] ?? '?';
- $message = "String must be at least {$min} characters long.";
- break;
- case 'maxLength': // Corrected casing
- $max = $args['max'] ?? '?';
- $message = "String must not be longer than {$max} characters.";
- break;
- case 'pattern':
- $pattern = $args['pattern'] ?? '?';
- $message = "String does not match the required pattern: `{$pattern}`.";
- break;
- case 'minimum':
- $min = $args['min'] ?? '?';
- $message = "Number must be greater than or equal to {$min}.";
- break;
- case 'maximum':
- $max = $args['max'] ?? '?';
- $message = "Number must be less than or equal to {$max}.";
- break;
- case 'exclusiveMinimum': // Corrected casing
- $min = $args['min'] ?? '?';
- $message = "Number must be strictly greater than {$min}.";
- break;
- case 'exclusiveMaximum': // Corrected casing
- $max = $args['max'] ?? '?';
- $message = "Number must be strictly less than {$max}.";
- break;
- case 'multipleOf': // Corrected casing
- $value = $args['value'] ?? '?';
- $message = "Number must be a multiple of {$value}.";
- break;
- case 'minItems': // Corrected casing
- $min = $args['min'] ?? '?';
- $message = "Array must contain at least {$min} items.";
- break;
- case 'maxItems': // Corrected casing
- $max = $args['max'] ?? '?';
- $message = "Array must contain no more than {$max} items.";
- break;
- case 'uniqueItems': // Corrected casing
- $message = 'Array items must be unique.';
- break;
- case 'minProperties': // Corrected casing
- $min = $args['min'] ?? '?';
- $message = "Object must have at least {$min} properties.";
- break;
- case 'maxProperties': // Corrected casing
- $max = $args['max'] ?? '?';
- $message = "Object must have no more than {$max} properties.";
- break;
- case 'additionalProperties': // Corrected casing
- $unexpected = $args['properties'] ?? [];
- $formattedUnexpected = implode(', ', array_map(static fn ($p) => "`{$p}`", $unexpected));
- $message = "Object contains unexpected additional properties: {$formattedUnexpected}.";
- break;
- case 'format':
- $format = $args['format'] ?? 'unknown';
- $message = "Value does not match the required format: `{$format}`.";
- break;
- default:
- $builtInMessage = $error->message();
- if ($builtInMessage && 'The data must match the schema' !== $builtInMessage) {
- $placeholders = $args;
- $builtInMessage = preg_replace_callback('/\{(\w+)\}/', static function ($match) use ($placeholders) {
- $key = $match[1];
- $value = $placeholders[$key] ?? '{'.$key.'}';
-
- return \is_array($value) ? json_encode($value) : (string) $value;
- }, $builtInMessage);
- $message = $builtInMessage;
- }
- break;
- }
-
- return $message;
- }
-}
diff --git a/src/Capability/Formatter/PromptResultFormatter.php b/src/Capability/Formatter/PromptResultFormatter.php
deleted file mode 100644
index 207c7e26..00000000
--- a/src/Capability/Formatter/PromptResultFormatter.php
+++ /dev/null
@@ -1,211 +0,0 @@
-
- * @author Mateu Aguiló Bosch
- */
-final class PromptResultFormatter
-{
- /**
- * Formats the raw result of a prompt generator into an array of MCP PromptMessages.
- *
- * @param mixed $promptGenerationResult expected: array of message structures
- *
- * @return PromptMessage[] array of PromptMessage objects
- *
- * @throws \RuntimeException if the result cannot be formatted
- * @throws \JsonException if JSON encoding fails
- */
- public function format(mixed $promptGenerationResult): array
- {
- if ($promptGenerationResult instanceof PromptMessage) {
- return [$promptGenerationResult];
- }
-
- if (!\is_array($promptGenerationResult)) {
- throw new RuntimeException('Prompt generator method must return an array of messages.');
- }
-
- if (empty($promptGenerationResult)) {
- return [];
- }
-
- if (\is_array($promptGenerationResult)) {
- $allArePromptMessages = true;
- $hasPromptMessages = false;
-
- foreach ($promptGenerationResult as $item) {
- if ($item instanceof PromptMessage) {
- $hasPromptMessages = true;
- } else {
- $allArePromptMessages = false;
- }
- }
-
- if ($allArePromptMessages && $hasPromptMessages) {
- return $promptGenerationResult;
- }
-
- if ($hasPromptMessages) {
- $result = [];
- foreach ($promptGenerationResult as $index => $item) {
- if ($item instanceof PromptMessage) {
- $result[] = $item;
- } else {
- $result = array_merge($result, $this->format($item));
- }
- }
-
- return $result;
- }
-
- if (!array_is_list($promptGenerationResult)) {
- if (isset($promptGenerationResult['user']) || isset($promptGenerationResult['assistant'])) {
- $result = [];
- if (isset($promptGenerationResult['user'])) {
- $userContent = $this->formatContent($promptGenerationResult['user']);
- $result[] = new PromptMessage(Role::User, $userContent);
- }
- if (isset($promptGenerationResult['assistant'])) {
- $assistantContent = $this->formatContent($promptGenerationResult['assistant']);
- $result[] = new PromptMessage(Role::Assistant, $assistantContent);
- }
-
- return $result;
- }
-
- if (isset($promptGenerationResult['role']) && isset($promptGenerationResult['content'])) {
- return [$this->formatMessage($promptGenerationResult)];
- }
-
- throw new RuntimeException('Associative array must contain either role/content keys or user/assistant keys.');
- }
-
- $formattedMessages = [];
- foreach ($promptGenerationResult as $index => $message) {
- if ($message instanceof PromptMessage) {
- $formattedMessages[] = $message;
- } else {
- $formattedMessages[] = $this->formatMessage($message, $index);
- }
- }
-
- return $formattedMessages;
- }
-
- throw new RuntimeException('Invalid prompt generation result format.');
- }
-
- /**
- * Formats a single message into a PromptMessage.
- */
- private function formatMessage(mixed $message, ?int $index = null): PromptMessage
- {
- $indexStr = null !== $index ? " at index {$index}" : '';
-
- if (!\is_array($message) || !\array_key_exists('role', $message) || !\array_key_exists('content', $message)) {
- throw new RuntimeException("Invalid message format{$indexStr}. Expected an array with 'role' and 'content' keys.");
- }
-
- $role = $message['role'] instanceof Role ? $message['role'] : Role::tryFrom($message['role']);
- if (null === $role) {
- throw new RuntimeException("Invalid role '{$message['role']}' in prompt message{$indexStr}. Only 'user' or 'assistant' are supported.");
- }
-
- $content = $this->formatContent($message['content'], $index);
-
- return new PromptMessage($role, $content);
- }
-
- /**
- * Formats content into a proper Content object.
- */
- private function formatContent(mixed $content, ?int $index = null): TextContent|ImageContent|AudioContent|ResourceLink|EmbeddedResource
- {
- $indexStr = null !== $index ? " at index {$index}" : '';
-
- if ($content instanceof Content) {
- if (
- $content instanceof TextContent || $content instanceof ImageContent
- || $content instanceof AudioContent || $content instanceof ResourceLink
- || $content instanceof EmbeddedResource
- ) {
- return $content;
- }
- throw new RuntimeException("Invalid Content type{$indexStr}. PromptMessage only supports TextContent, ImageContent, AudioContent, ResourceLink, or EmbeddedResource.");
- }
-
- if (\is_string($content)) {
- return new TextContent($content);
- }
-
- if (\is_array($content) && isset($content['type'])) {
- return $this->formatTypedContent($content, $index);
- }
-
- if (\is_scalar($content) || null === $content) {
- $stringContent = null === $content ? '(null)' : (\is_bool($content) ? ($content ? 'true' : 'false') : (string) $content);
-
- return new TextContent($stringContent);
- }
-
- $jsonContent = json_encode($content, \JSON_PRETTY_PRINT | \JSON_UNESCAPED_SLASHES | \JSON_UNESCAPED_UNICODE | \JSON_THROW_ON_ERROR);
-
- return new TextContent($jsonContent);
- }
-
- /**
- * Formats typed content arrays into Content objects.
- *
- * Delegates to the schema classes' fromArray() so optional fields
- * (annotations, mimeType, _meta, ...) carry over instead of being dropped.
- *
- * @param array $content
- */
- private function formatTypedContent(array $content, ?int $index = null): TextContent|ImageContent|AudioContent|ResourceLink|EmbeddedResource
- {
- $indexStr = null !== $index ? " at index {$index}" : '';
- $type = $content['type'];
-
- if ('resource' === $type && isset($content['resource']) && \is_array($content['resource']) && !isset($content['resource']['mimeType'])) {
- // EmbeddedResource::fromArray() leaves a missing mimeType unset; this
- // formatter has always defaulted it, so keep that for compatibility.
- $content['resource']['mimeType'] = isset($content['resource']['text']) ? 'text/plain' : 'application/octet-stream';
- }
-
- try {
- return match ($type) {
- 'text' => TextContent::fromArray($content),
- 'image' => ImageContent::fromArray($content),
- 'audio' => AudioContent::fromArray($content),
- 'resource' => EmbeddedResource::fromArray($content),
- 'resource_link' => ResourceLink::fromArray($content),
- default => throw new RuntimeException("Invalid content type '{$type}'{$indexStr}."),
- };
- } catch (InvalidArgumentException $e) {
- throw new RuntimeException("Invalid '{$type}' content{$indexStr}: {$e->getMessage()}", 0, $e);
- }
- }
-}
diff --git a/src/Capability/Formatter/ResourceResultFormatter.php b/src/Capability/Formatter/ResourceResultFormatter.php
deleted file mode 100644
index bac2bc77..00000000
--- a/src/Capability/Formatter/ResourceResultFormatter.php
+++ /dev/null
@@ -1,200 +0,0 @@
-
- * @author Mateu Aguiló Bosch
- */
-final class ResourceResultFormatter
-{
- /**
- * Formats the raw result of a resource read operation into MCP ResourceContent items.
- *
- * @param mixed $readResult the raw result from the resource handler method
- * @param string $uri the URI of the resource that was read
- * @param string|null $mimeType the MIME type from the ResourceDefinition
- * @param mixed $meta optional metadata to include in the ResourceContents
- *
- * @return ResourceContents[] array of ResourceContents objects
- *
- * @throws RuntimeException If the result cannot be formatted.
- *
- * Supported result types:
- * - ResourceContents: Used as-is
- * - EmbeddedResource: Resource is extracted from the EmbeddedResource
- * - string: Converted to text content with guessed or provided MIME type
- * - stream resource: Read and converted to blob with provided MIME type
- * - array with 'blob' key: Used as blob content
- * - array with 'text' key: Used as text content
- * - SplFileInfo: Read and converted to blob
- * - array: Converted to JSON if MIME type is application/json or contains 'json'
- * For other MIME types, will try to convert to JSON with a warning
- */
- public function format(mixed $readResult, string $uri, ?string $mimeType = null, mixed $meta = null): array
- {
- if ($readResult instanceof ResourceContents) {
- return [$readResult];
- }
-
- if ($readResult instanceof EmbeddedResource) {
- return [$readResult->resource];
- }
-
- if (\is_array($readResult)) {
- if (empty($readResult)) {
- return [new TextResourceContents($uri, 'application/json', '[]', $meta)];
- }
-
- $allAreResourceContents = true;
- $hasResourceContents = false;
- $allAreEmbeddedResource = true;
- $hasEmbeddedResource = false;
-
- foreach ($readResult as $item) {
- if ($item instanceof ResourceContents) {
- $hasResourceContents = true;
- $allAreEmbeddedResource = false;
- } elseif ($item instanceof EmbeddedResource) {
- $hasEmbeddedResource = true;
- $allAreResourceContents = false;
- } else {
- $allAreResourceContents = false;
- $allAreEmbeddedResource = false;
- }
- }
-
- if ($allAreResourceContents && $hasResourceContents) {
- return $readResult;
- }
-
- if ($allAreEmbeddedResource && $hasEmbeddedResource) {
- return array_map(static fn ($item) => $item->resource, $readResult);
- }
-
- if ($hasResourceContents || $hasEmbeddedResource) {
- $result = [];
- foreach ($readResult as $item) {
- if ($item instanceof ResourceContents) {
- $result[] = $item;
- } elseif ($item instanceof EmbeddedResource) {
- $result[] = $item->resource;
- } else {
- $result = array_merge($result, $this->format($item, $uri, $mimeType, $meta));
- }
- }
-
- return $result;
- }
- }
-
- if (\is_string($readResult)) {
- $mimeType = $mimeType ?? $this->guessMimeTypeFromString($readResult);
-
- return [new TextResourceContents($uri, $mimeType, $readResult, $meta)];
- }
-
- if (\is_resource($readResult) && 'stream' === get_resource_type($readResult)) {
- $result = BlobResourceContents::fromStream(
- $uri,
- $readResult,
- $mimeType ?? 'application/octet-stream',
- $meta
- );
-
- @fclose($readResult);
-
- return [$result];
- }
-
- if (\is_array($readResult) && isset($readResult['blob']) && \is_string($readResult['blob'])) {
- $mimeType = $readResult['mimeType'] ?? $mimeType ?? 'application/octet-stream';
-
- return [new BlobResourceContents($uri, $mimeType, $readResult['blob'], $meta)];
- }
-
- if (\is_array($readResult) && isset($readResult['text']) && \is_string($readResult['text'])) {
- $mimeType = $readResult['mimeType'] ?? $mimeType ?? 'text/plain';
-
- return [new TextResourceContents($uri, $mimeType, $readResult['text'], $meta)];
- }
-
- if ($readResult instanceof \SplFileInfo && $readResult->isFile() && $readResult->isReadable()) {
- if ($mimeType && str_contains(strtolower($mimeType), 'text')) {
- return [new TextResourceContents($uri, $mimeType, file_get_contents($readResult->getPathname()), $meta)];
- }
-
- return [BlobResourceContents::fromSplFileInfo($uri, $readResult, $mimeType, $meta)];
- }
-
- if (\is_array($readResult)) {
- if ($mimeType && (str_contains(strtolower($mimeType), 'json')
- || 'application/json' === $mimeType)) {
- try {
- $jsonString = json_encode($readResult, \JSON_THROW_ON_ERROR | \JSON_PRETTY_PRINT);
-
- return [new TextResourceContents($uri, $mimeType, $jsonString, $meta)];
- } catch (\JsonException $e) {
- throw new RuntimeException(\sprintf('Failed to encode array as JSON for URI "%s": %s', $uri, $e->getMessage()));
- }
- }
-
- try {
- $jsonString = json_encode($readResult, \JSON_THROW_ON_ERROR | \JSON_PRETTY_PRINT);
- $mimeType = $mimeType ?? 'application/json';
-
- return [new TextResourceContents($uri, $mimeType, $jsonString, $meta)];
- } catch (\JsonException $e) {
- throw new RuntimeException(\sprintf('Failed to encode array as JSON for URI "%s": %s', $uri, $e->getMessage()));
- }
- }
-
- throw new RuntimeException(\sprintf('Cannot format resource read result for URI "%s". Handler method returned unhandled type: ', $uri).\gettype($readResult));
- }
-
- /**
- * Guesses MIME type from string content (very basic).
- */
- private function guessMimeTypeFromString(string $content): string
- {
- $trimmed = ltrim($content);
-
- if (str_starts_with($trimmed, '<') && str_ends_with(rtrim($content), '>')) {
- if (str_contains($trimmed, '
- * @author Mateu Aguiló Bosch
- */
-final class ToolResultFormatter
-{
- /**
- * Formats the result of a tool execution into an array of MCP Content items.
- *
- * - If the result is already a Content object, it's wrapped in an array.
- * - If the result is an array:
- * - If all elements are Content objects, the array is returned as is.
- * - If it's a mixed array (Content and non-Content items), non-Content items are
- * individually formatted (scalars to TextContent, others to JSON TextContent).
- * - If it's an array with no Content items, the entire array is JSON-encoded into a single TextContent.
- * - Scalars (string, int, float, bool) are wrapped in TextContent.
- * - null is represented as TextContent('(null)').
- * - Other objects are JSON-encoded and wrapped in TextContent.
- *
- * @param mixed $toolExecutionResult the raw value returned by the tool's PHP method
- *
- * @return Content[] the content items for CallToolResult
- *
- * @throws \JsonException if JSON encoding fails for non-Content array/object results
- */
- public function format(mixed $toolExecutionResult): array
- {
- if ($toolExecutionResult instanceof Content) {
- return [$toolExecutionResult];
- }
-
- if (\is_array($toolExecutionResult)) {
- if (empty($toolExecutionResult)) {
- return [new TextContent('[]')];
- }
-
- $allAreContent = true;
- $hasContent = false;
-
- foreach ($toolExecutionResult as $item) {
- if ($item instanceof Content) {
- $hasContent = true;
- } else {
- $allAreContent = false;
- }
- }
-
- if ($allAreContent && $hasContent) {
- return $toolExecutionResult;
- }
-
- if ($hasContent) {
- $result = [];
- foreach ($toolExecutionResult as $item) {
- if ($item instanceof Content) {
- $result[] = $item;
- } else {
- $result = array_merge($result, $this->format($item));
- }
- }
-
- return $result;
- }
- }
-
- if (null === $toolExecutionResult) {
- return [new TextContent('(null)')];
- }
-
- if (\is_bool($toolExecutionResult)) {
- return [new TextContent($toolExecutionResult ? 'true' : 'false')];
- }
-
- if (\is_scalar($toolExecutionResult)) {
- return [new TextContent($toolExecutionResult)];
- }
-
- $jsonResult = json_encode(
- $toolExecutionResult,
- \JSON_PRETTY_PRINT | \JSON_UNESCAPED_SLASHES | \JSON_UNESCAPED_UNICODE | \JSON_THROW_ON_ERROR | \JSON_INVALID_UTF8_SUBSTITUTE
- );
-
- return [new TextContent($jsonResult)];
- }
-}
diff --git a/src/Capability/Logger/ClientLogger.php b/src/Capability/Logger/ClientLogger.php
deleted file mode 100644
index 1e0a959a..00000000
--- a/src/Capability/Logger/ClientLogger.php
+++ /dev/null
@@ -1,99 +0,0 @@
-
- * @author Tobias Nyholm
- */
-final class ClientLogger extends AbstractLogger
-{
- public function __construct(
- private ClientGateway $client,
- private SessionInterface $session,
- ) {
- }
-
- /**
- * Logs with an arbitrary level.
- *
- * @param string|\Stringable $message
- * @param array $context
- */
- public function log($level, $message, array $context = []): void
- {
- // Convert PSR-3 level to MCP LoggingLevel
- $mcpLevel = $this->convertToMcpLevel($level);
- if (null === $mcpLevel) {
- return; // Unknown level, skip MCP notification
- }
-
- $minimumLevel = $this->session->get(Protocol::SESSION_LOGGING_LEVEL, '');
- $minimumLevel = LoggingLevel::tryFrom($minimumLevel) ?? LoggingLevel::Warning;
-
- if ($this->getSeverityIndex($minimumLevel) > $this->getSeverityIndex($mcpLevel)) {
- return;
- }
-
- $this->client->log($mcpLevel, $message);
- }
-
- /**
- * Converts PSR-3 log level to MCP LoggingLevel.
- *
- * @param mixed $level PSR-3 level
- *
- * @return LoggingLevel|null MCP level or null if unknown
- */
- private function convertToMcpLevel($level): ?LoggingLevel
- {
- return match (strtolower((string) $level)) {
- 'emergency' => LoggingLevel::Emergency,
- 'alert' => LoggingLevel::Alert,
- 'critical' => LoggingLevel::Critical,
- 'error' => LoggingLevel::Error,
- 'warning' => LoggingLevel::Warning,
- 'notice' => LoggingLevel::Notice,
- 'info' => LoggingLevel::Info,
- 'debug' => LoggingLevel::Debug,
- default => null,
- };
- }
-
- /**
- * Gets the severity index for this log level.
- * Higher values indicate more severe log levels.
- *
- * @return int Severity index (0-7, where 7 is most severe)
- */
- private function getSeverityIndex(LoggingLevel $level): int
- {
- return match ($level) {
- LoggingLevel::Debug => 0,
- LoggingLevel::Info => 1,
- LoggingLevel::Notice => 2,
- LoggingLevel::Warning => 3,
- LoggingLevel::Error => 4,
- LoggingLevel::Critical => 5,
- LoggingLevel::Alert => 6,
- LoggingLevel::Emergency => 7,
- };
- }
-}
diff --git a/src/Capability/Registry.php b/src/Capability/Registry.php
deleted file mode 100644
index f90a4ca1..00000000
--- a/src/Capability/Registry.php
+++ /dev/null
@@ -1,451 +0,0 @@
-
- */
-final class Registry implements RegistryInterface
-{
- /**
- * @var array
- */
- private array $tools = [];
-
- /**
- * @var array
- */
- private array $resources = [];
-
- /**
- * @var array
- */
- private array $prompts = [];
-
- /**
- * @var array
- */
- private array $resourceTemplates = [];
-
- private bool $loaded = false;
-
- private bool $loading = false;
-
- public function __construct(
- private readonly ?EventDispatcherInterface $eventDispatcher = null,
- private readonly LoggerInterface $logger = new NullLogger(),
- private readonly NameValidator $nameValidator = new NameValidator(),
- private readonly ?LoaderInterface $loader = null,
- ) {
- }
-
- /**
- * Runs the configured loader once, on demand. Reads trigger this automatically, so element
- * loading is deferred to the first read (request time) rather than eager at build time — under a
- * persistent runtime a source not yet ready at build no longer freezes the registry empty.
- *
- * `loaded` is set only after success, so a transient failure is retried on the next read. The
- * `loading` guard lets a loader read the registry during its own run (e.g. discovery's identity
- * check) without re-entering the load.
- */
- public function load(): void
- {
- if ($this->loaded || $this->loading || null === $this->loader) {
- return;
- }
-
- $this->loading = true;
- try {
- $this->loader->load($this);
- $this->loaded = true;
- } finally {
- $this->loading = false;
- }
- }
-
- public function registerTool(Tool $tool, callable|array|string $handler): ToolReference
- {
- if (!$this->nameValidator->isValid($tool->name)) {
- $this->logger->warning(
- \sprintf('Tool name "%s" is invalid. Tool names should only contain letters (a-z, A-Z), numbers, dots, hyphens, underscores, and forward slashes.', $tool->name),
- );
- }
-
- $reference = new ToolReference($tool, $handler);
- $this->tools[$tool->name] = $reference;
-
- $this->eventDispatcher?->dispatch(new ToolListChangedEvent());
-
- return $reference;
- }
-
- public function registerResource(ResourceDefinition $resource, callable|array|string $handler): ResourceReference
- {
- $reference = new ResourceReference($resource, $handler);
- $this->resources[$resource->uri] = $reference;
-
- $this->eventDispatcher?->dispatch(new ResourceListChangedEvent());
-
- return $reference;
- }
-
- public function registerResourceTemplate(
- ResourceTemplate $template,
- callable|array|string $handler,
- array $completionProviders = [],
- ): ResourceTemplateReference {
- $reference = new ResourceTemplateReference($template, $handler, $completionProviders);
- $this->resourceTemplates[$template->uriTemplate] = $reference;
-
- $this->eventDispatcher?->dispatch(new ResourceTemplateListChangedEvent());
-
- return $reference;
- }
-
- public function registerPrompt(
- Prompt $prompt,
- callable|array|string $handler,
- array $completionProviders = [],
- ): PromptReference {
- $reference = new PromptReference($prompt, $handler, $completionProviders);
- $this->prompts[$prompt->name] = $reference;
-
- $this->eventDispatcher?->dispatch(new PromptListChangedEvent());
-
- return $reference;
- }
-
- public function unregisterTool(string $name): void
- {
- if (!isset($this->tools[$name])) {
- return;
- }
-
- unset($this->tools[$name]);
-
- $this->eventDispatcher?->dispatch(new ToolListChangedEvent());
- }
-
- public function unregisterResource(string $uri): void
- {
- if (!isset($this->resources[$uri])) {
- return;
- }
-
- unset($this->resources[$uri]);
-
- $this->eventDispatcher?->dispatch(new ResourceListChangedEvent());
- }
-
- public function unregisterResourceTemplate(string $uriTemplate): void
- {
- if (!isset($this->resourceTemplates[$uriTemplate])) {
- return;
- }
-
- unset($this->resourceTemplates[$uriTemplate]);
-
- $this->eventDispatcher?->dispatch(new ResourceTemplateListChangedEvent());
- }
-
- public function unregisterPrompt(string $name): void
- {
- if (!isset($this->prompts[$name])) {
- return;
- }
-
- unset($this->prompts[$name]);
-
- $this->eventDispatcher?->dispatch(new PromptListChangedEvent());
- }
-
- public function hasTool(string $name): bool
- {
- $this->load();
-
- return isset($this->tools[$name]);
- }
-
- public function hasResource(string $uri): bool
- {
- $this->load();
-
- return isset($this->resources[$uri]);
- }
-
- public function hasResourceTemplate(string $uriTemplate): bool
- {
- $this->load();
-
- return isset($this->resourceTemplates[$uriTemplate]);
- }
-
- public function hasPrompt(string $name): bool
- {
- $this->load();
-
- return isset($this->prompts[$name]);
- }
-
- public function hasTools(): bool
- {
- $this->load();
-
- return [] !== $this->tools;
- }
-
- public function getTools(?int $limit = null, ?string $cursor = null): Page
- {
- $this->load();
-
- $tools = [];
- foreach ($this->tools as $toolReference) {
- $tools[$toolReference->tool->name] = $toolReference->tool;
- }
-
- if (null === $limit) {
- return new Page($tools, null);
- }
-
- $paginatedTools = $this->paginateResults($tools, $limit, $cursor);
-
- $nextCursor = $this->calculateNextCursor(
- \count($tools),
- $cursor,
- $limit
- );
-
- return new Page($paginatedTools, $nextCursor);
- }
-
- public function getTool(string $name): ToolReference
- {
- $this->load();
-
- return $this->tools[$name] ?? throw new ToolNotFoundException($name);
- }
-
- public function hasResources(): bool
- {
- $this->load();
-
- return [] !== $this->resources;
- }
-
- public function getResources(?int $limit = null, ?string $cursor = null): Page
- {
- $this->load();
-
- $resources = [];
- foreach ($this->resources as $resourceReference) {
- $resources[$resourceReference->resource->uri] = $resourceReference->resource;
- }
-
- if (null === $limit) {
- return new Page($resources, null);
- }
-
- $paginatedResources = $this->paginateResults($resources, $limit, $cursor);
-
- $nextCursor = $this->calculateNextCursor(
- \count($resources),
- $cursor,
- $limit
- );
-
- return new Page($paginatedResources, $nextCursor);
- }
-
- public function getResource(
- string $uri,
- bool $includeTemplates = true,
- ): ResourceReference|ResourceTemplateReference {
- $this->load();
-
- $registration = $this->resources[$uri] ?? null;
- if ($registration) {
- return $registration;
- }
-
- if ($includeTemplates) {
- foreach ($this->resourceTemplates as $template) {
- if ($template->matches($uri)) {
- return $template;
- }
- }
- }
-
- $this->logger->debug('No resource matched URI.', ['uri' => $uri]);
-
- throw new ResourceNotFoundException($uri);
- }
-
- public function hasResourceTemplates(): bool
- {
- $this->load();
-
- return [] !== $this->resourceTemplates;
- }
-
- public function getResourceTemplates(?int $limit = null, ?string $cursor = null): Page
- {
- $this->load();
-
- $templates = [];
- foreach ($this->resourceTemplates as $templateReference) {
- $templates[$templateReference->resourceTemplate->uriTemplate] = $templateReference->resourceTemplate;
- }
-
- if (null === $limit) {
- return new Page($templates, null);
- }
-
- $paginatedTemplates = $this->paginateResults($templates, $limit, $cursor);
-
- $nextCursor = $this->calculateNextCursor(
- \count($templates),
- $cursor,
- $limit
- );
-
- return new Page($paginatedTemplates, $nextCursor);
- }
-
- public function getResourceTemplate(string $uriTemplate): ResourceTemplateReference
- {
- $this->load();
-
- return $this->resourceTemplates[$uriTemplate] ?? throw new ResourceNotFoundException($uriTemplate);
- }
-
- public function hasPrompts(): bool
- {
- $this->load();
-
- return [] !== $this->prompts;
- }
-
- public function getPrompts(?int $limit = null, ?string $cursor = null): Page
- {
- $this->load();
-
- $prompts = [];
- foreach ($this->prompts as $promptReference) {
- $prompts[$promptReference->prompt->name] = $promptReference->prompt;
- }
-
- if (null === $limit) {
- return new Page($prompts, null);
- }
-
- $paginatedPrompts = $this->paginateResults($prompts, $limit, $cursor);
-
- $nextCursor = $this->calculateNextCursor(
- \count($prompts),
- $cursor,
- $limit
- );
-
- return new Page($paginatedPrompts, $nextCursor);
- }
-
- public function getPrompt(string $name): PromptReference
- {
- $this->load();
-
- return $this->prompts[$name] ?? throw new PromptNotFoundException($name);
- }
-
- /**
- * Calculate next cursor for pagination.
- *
- * @param int $totalItems Count of all items
- * @param string|null $currentCursor Current cursor position
- * @param int $limit Number requested/returned per page
- */
- private function calculateNextCursor(int $totalItems, ?string $currentCursor, int $limit): ?string
- {
- $currentOffset = 0;
-
- if (null !== $currentCursor) {
- $decodedCursor = base64_decode($currentCursor, true);
- if (false !== $decodedCursor && is_numeric($decodedCursor)) {
- $currentOffset = (int) $decodedCursor;
- }
- }
-
- $nextOffset = $currentOffset + $limit;
-
- if ($nextOffset < $totalItems) {
- return base64_encode((string) $nextOffset);
- }
-
- return null;
- }
-
- /**
- * Helper method to paginate results using cursor-based pagination.
- *
- * @param array $items The full array of items to paginate The full array of items to paginate
- * @param int $limit Maximum number of items to return
- * @param string|null $cursor Base64 encoded offset position
- *
- * @return array Paginated results
- *
- * @throws InvalidCursorException When cursor is invalid (MCP error code -32602)
- */
- private function paginateResults(array $items, int $limit, ?string $cursor = null): array
- {
- $offset = 0;
- if (null !== $cursor) {
- $decodedCursor = base64_decode($cursor, true);
-
- if (false === $decodedCursor || !is_numeric($decodedCursor)) {
- throw new InvalidCursorException($cursor);
- }
-
- $offset = (int) $decodedCursor;
-
- // Validate offset is within reasonable bounds
- if ($offset < 0 || $offset > \count($items)) {
- throw new InvalidCursorException($cursor);
- }
- }
-
- return array_values(\array_slice($items, $offset, $limit));
- }
-}
diff --git a/src/Capability/Registry/Container.php b/src/Capability/Registry/Container.php
deleted file mode 100644
index 92c4a908..00000000
--- a/src/Capability/Registry/Container.php
+++ /dev/null
@@ -1,188 +0,0 @@
-
- */
-final class Container implements ContainerInterface
-{
- /**
- * @var array Cache for already created instances (shared singletons)
- */
- private array $instances = [];
-
- /**
- * @var array Track classes currently being resolved to detect circular dependencies
- */
- private array $resolving = [];
-
- /**
- * Finds an entry of the container by its identifier and returns it.
- *
- * @param string $id identifier of the entry to look for (usually a FQCN)
- *
- * @return mixed entry
- *
- * @throws NotFoundExceptionInterface no entry was found for **this** identifier
- * @throws ContainerExceptionInterface Error while retrieving the entry (e.g., dependency resolution failure, circular dependency).
- */
- public function get(string $id): mixed
- {
- // 1. Check instance cache
- if (isset($this->instances[$id])) {
- return $this->instances[$id];
- }
-
- // 2. Check if class exists
- if (!class_exists($id) && !interface_exists($id)) { // Also check interface for bindings
- throw new ServiceNotFoundException(\sprintf('Class, interface, or entry "%s" not found.', $id));
- }
-
- // 7. Circular Dependency Check
- if (isset($this->resolving[$id])) {
- throw new ContainerException("Circular dependency detected while resolving '{$id}'. Resolution path: ".implode(' -> ', array_keys($this->resolving))." -> {$id}");
- }
-
- $this->resolving[$id] = true; // Mark as currently resolving
-
- try {
- // 3. Reflect on the class
- $reflector = new \ReflectionClass($id);
-
- // Check if class is instantiable (abstract classes, interfaces cannot be directly instantiated)
- if (!$reflector->isInstantiable()) {
- // We might have an interface bound to a concrete class via set()
- // This check is slightly redundant due to class_exists but good practice
- throw new ContainerException("Class '{$id}' is not instantiable (e.g., abstract class or interface without explicit binding).");
- }
-
- // 4. Get the constructor
- $constructor = $reflector->getConstructor();
-
- // 5. If no constructor or constructor has no parameters, instantiate directly
- if (null === $constructor || 0 === $constructor->getNumberOfParameters()) {
- $instance = $reflector->newInstance();
- } else {
- // 6. Constructor has parameters, attempt to resolve them
- $parameters = $constructor->getParameters();
- $resolvedArgs = [];
-
- foreach ($parameters as $parameter) {
- $resolvedArgs[] = $this->resolveParameter($parameter, $id);
- }
-
- // Instantiate with resolved arguments
- $instance = $reflector->newInstanceArgs($resolvedArgs);
- }
-
- // Cache the instance
- $this->instances[$id] = $instance;
-
- return $instance;
- } catch (\ReflectionException $e) {
- throw new ContainerException(\sprintf('Reflection failed for %s.', $id), 0, $e);
- } catch (ContainerExceptionInterface $e) { // Re-throw container exceptions directly
- throw $e;
- } catch (\Throwable $e) { // Catch other instantiation errors
- throw new ContainerException("Failed to instantiate or resolve dependencies for '{$id}': ".$e->getMessage(), (int) $e->getCode(), $e);
- } finally {
- // 7. Remove from resolving stack once done (success or failure)
- unset($this->resolving[$id]);
- }
- }
-
- /**
- * Attempts to resolve a single constructor parameter.
- *
- * @throws ContainerExceptionInterface if a required dependency cannot be resolved
- */
- private function resolveParameter(\ReflectionParameter $parameter, string $consumerClassId): mixed
- {
- // Check for type hint
- $type = $parameter->getType();
-
- if ($type instanceof \ReflectionNamedType && !$type->isBuiltin()) {
- // Type hint is a class or interface name
- $typeName = $type->getName();
- try {
- // Recursively get the dependency
- return $this->get($typeName);
- } catch (NotFoundExceptionInterface $e) {
- // Dependency class not found, fail ONLY if required
- if (!$parameter->isOptional() && !$parameter->allowsNull()) {
- throw new ContainerException("Unresolvable dependency '{$typeName}' required by '{$consumerClassId}' constructor parameter \${$parameter->getName()}.", 0, $e);
- }
- // If optional or nullable, proceed (will check allowsNull/Default below)
- } catch (ContainerExceptionInterface $e) {
- // Dependency itself failed to resolve (e.g., its own deps, circular)
- throw new ContainerException("Failed to resolve dependency '{$typeName}' for '{$consumerClassId}' parameter \${$parameter->getName()}: ".$e->getMessage(), 0, $e);
- }
- }
-
- // Check if parameter has a default value
- if ($parameter->isDefaultValueAvailable()) {
- return $parameter->getDefaultValue();
- }
-
- // Check if parameter allows null (and wasn't resolved above)
- if ($parameter->allowsNull()) {
- return null;
- }
-
- // Check if it was a built-in type without a default (unresolvable by this basic container)
- if ($type instanceof \ReflectionNamedType && $type->isBuiltin()) {
- throw new ContainerException("Cannot auto-wire built-in type '{$type->getName()}' for required parameter \${$parameter->getName()} in '{$consumerClassId}' constructor. Provide a default value or use a more advanced container.");
- }
-
- // Check if it was a union/intersection type without a default (also unresolvable)
- if (null !== $type && !$type instanceof \ReflectionNamedType) {
- throw new ContainerException("Cannot auto-wire complex type (union/intersection) for required parameter \${$parameter->getName()} in '{$consumerClassId}' constructor. Provide a default value or use a more advanced container.");
- }
-
- // If we reach here, it's an untyped, required parameter without a default.
- // Or potentially an unresolvable optional class dependency where null is not allowed (edge case).
- throw new ContainerException("Cannot resolve required parameter \${$parameter->getName()} for '{$consumerClassId}' constructor (untyped or unresolvable complex type).");
- }
-
- /**
- * Returns true if the container can return an entry for the given identifier.
- * Checks explicitly set instances and if the class/interface exists.
- * Does not guarantee `get()` will succeed if auto-wiring fails.
- */
- public function has(string $id): bool
- {
- return isset($this->instances[$id]) || class_exists($id) || interface_exists($id);
- }
-
- /**
- * Adds a pre-built instance or a factory/binding to the container.
- * This basic version only supports pre-built instances (singletons).
- */
- public function set(string $id, object $instance): void
- {
- // Could add support for closures/factories later if needed
- $this->instances[$id] = $instance;
- }
-}
diff --git a/src/Capability/Registry/ElementReference.php b/src/Capability/Registry/ElementReference.php
deleted file mode 100644
index a49b2eb3..00000000
--- a/src/Capability/Registry/ElementReference.php
+++ /dev/null
@@ -1,28 +0,0 @@
-
- */
-class ElementReference
-{
- /**
- * @param Handler $handler
- */
- public function __construct(
- public readonly \Closure|array|string $handler,
- ) {
- }
-}
diff --git a/src/Capability/Registry/Loader/ChainLoader.php b/src/Capability/Registry/Loader/ChainLoader.php
deleted file mode 100644
index 6c30a160..00000000
--- a/src/Capability/Registry/Loader/ChainLoader.php
+++ /dev/null
@@ -1,38 +0,0 @@
-
- */
-final class ChainLoader implements LoaderInterface
-{
- /**
- * @param LoaderInterface[] $loaders
- */
- public function __construct(
- private readonly array $loaders,
- ) {
- }
-
- public function load(RegistryInterface $registry): void
- {
- foreach ($this->loaders as $loader) {
- $loader->load($registry);
- }
- }
-}
diff --git a/src/Capability/Registry/Loader/DiscoveryLoader.php b/src/Capability/Registry/Loader/DiscoveryLoader.php
deleted file mode 100644
index d2b95384..00000000
--- a/src/Capability/Registry/Loader/DiscoveryLoader.php
+++ /dev/null
@@ -1,185 +0,0 @@
-
- */
-final class DiscoveryLoader implements LoaderInterface
-{
- private DiscoveryState $owned;
-
- /**
- * @param string[] $scanDirs
- * @param string[] $excludeDirs
- * @param string[] $namePatterns
- */
- public function __construct(
- private string $basePath,
- private array $scanDirs,
- private array $excludeDirs,
- private DiscovererInterface $discoverer,
- private array $namePatterns = DiscovererInterface::DEFAULT_NAME_PATERNS,
- private LoggerInterface $logger = new NullLogger(),
- ) {
- $this->owned = new DiscoveryState();
- }
-
- public function load(RegistryInterface $registry): void
- {
- $discovered = $this->discoverer->discover($this->basePath, $this->scanDirs, $this->excludeDirs, $this->namePatterns);
-
- $this->unregisterOwned($registry, $this->owned->obsoletedBy($discovered));
- $this->owned = $this->writeDiscovered($registry, $discovered);
- }
-
- /**
- * Unregisters entries we previously wrote that the registry still attributes to us.
- * Entries overwritten by someone else are left untouched (identity check fails).
- */
- private function unregisterOwned(RegistryInterface $registry, DiscoveryState $obsolete): void
- {
- foreach ($obsolete->getTools() as $name => $owned) {
- if ($registry->hasTool($name) && $registry->getTool($name) === $owned) {
- $registry->unregisterTool($name);
- }
- }
- foreach ($obsolete->getResources() as $uri => $owned) {
- if ($registry->hasResource($uri) && $registry->getResource($uri, false) === $owned) {
- $registry->unregisterResource($uri);
- }
- }
- foreach ($obsolete->getResourceTemplates() as $uriTemplate => $owned) {
- if ($registry->hasResourceTemplate($uriTemplate) && $registry->getResourceTemplate($uriTemplate) === $owned) {
- $registry->unregisterResourceTemplate($uriTemplate);
- }
- }
- foreach ($obsolete->getPrompts() as $name => $owned) {
- if ($registry->hasPrompt($name) && $registry->getPrompt($name) === $owned) {
- $registry->unregisterPrompt($name);
- }
- }
- }
-
- /**
- * Writes the discovered state into the registry, skipping entries that a conflicting
- * registration already holds. Returns the new owned state (only the writes we actually performed).
- */
- private function writeDiscovered(RegistryInterface $registry, DiscoveryState $discovered): DiscoveryState
- {
- $tools = [];
- foreach ($discovered->getTools() as $name => $reference) {
- if (!$this->mayWriteTool($registry, $name)) {
- continue;
- }
- $tools[$name] = $registry->registerTool($reference->tool, $reference->handler);
- }
-
- $resources = [];
- foreach ($discovered->getResources() as $uri => $reference) {
- if (!$this->mayWriteResource($registry, $uri)) {
- continue;
- }
- $resources[$uri] = $registry->registerResource($reference->resource, $reference->handler);
- }
-
- $resourceTemplates = [];
- foreach ($discovered->getResourceTemplates() as $uriTemplate => $reference) {
- if (!$this->mayWriteResourceTemplate($registry, $uriTemplate)) {
- continue;
- }
- $resourceTemplates[$uriTemplate] = $registry->registerResourceTemplate(
- $reference->resourceTemplate,
- $reference->handler,
- $reference->completionProviders,
- );
- }
-
- $prompts = [];
- foreach ($discovered->getPrompts() as $name => $reference) {
- if (!$this->mayWritePrompt($registry, $name)) {
- continue;
- }
- $prompts[$name] = $registry->registerPrompt(
- $reference->prompt,
- $reference->handler,
- $reference->completionProviders,
- );
- }
-
- return new DiscoveryState($tools, $resources, $prompts, $resourceTemplates);
- }
-
- private function mayWriteTool(RegistryInterface $registry, string $name): bool
- {
- if (!$registry->hasTool($name) || $registry->getTool($name) === ($this->owned->getTools()[$name] ?? null)) {
- return true;
- }
-
- $this->logger->debug(\sprintf(
- 'Ignoring discovered tool "%s": a conflicting manual or runtime registration already exists.',
- $name,
- ));
-
- return false;
- }
-
- private function mayWriteResource(RegistryInterface $registry, string $uri): bool
- {
- if (!$registry->hasResource($uri) || $registry->getResource($uri, false) === ($this->owned->getResources()[$uri] ?? null)) {
- return true;
- }
-
- $this->logger->debug(\sprintf(
- 'Ignoring discovered resource "%s": a conflicting manual or runtime registration already exists.',
- $uri,
- ));
-
- return false;
- }
-
- private function mayWriteResourceTemplate(RegistryInterface $registry, string $uriTemplate): bool
- {
- if (!$registry->hasResourceTemplate($uriTemplate) || $registry->getResourceTemplate($uriTemplate) === ($this->owned->getResourceTemplates()[$uriTemplate] ?? null)) {
- return true;
- }
-
- $this->logger->debug(\sprintf(
- 'Ignoring discovered resource template "%s": a conflicting manual or runtime registration already exists.',
- $uriTemplate,
- ));
-
- return false;
- }
-
- private function mayWritePrompt(RegistryInterface $registry, string $name): bool
- {
- if (!$registry->hasPrompt($name) || $registry->getPrompt($name) === ($this->owned->getPrompts()[$name] ?? null)) {
- return true;
- }
-
- $this->logger->debug(\sprintf(
- 'Ignoring discovered prompt "%s": a conflicting manual or runtime registration already exists.',
- $name,
- ));
-
- return false;
- }
-}
diff --git a/src/Capability/Registry/Loader/ExplicitElementLoader.php b/src/Capability/Registry/Loader/ExplicitElementLoader.php
deleted file mode 100644
index 4733ab97..00000000
--- a/src/Capability/Registry/Loader/ExplicitElementLoader.php
+++ /dev/null
@@ -1,105 +0,0 @@
-
- */
-final class ExplicitElementLoader implements LoaderInterface
-{
- /**
- * @param list $tools
- * @param list $resources
- * @param list}> $resourceTemplates
- * @param list}> $prompts
- */
- public function __construct(
- private readonly array $tools = [],
- private readonly array $resources = [],
- private readonly array $resourceTemplates = [],
- private readonly array $prompts = [],
- ) {
- }
-
- public function load(RegistryInterface $registry): void
- {
- foreach ($this->tools as $entry) {
- $handler = $entry['handler'];
- $registry->registerTool($entry['definition'], $this->boundClosure(
- static function (array $arguments) use ($handler): mixed {
- $gateway = new ClientGateway($arguments['_session']);
- unset($arguments['_session'], $arguments['_request']);
-
- return $handler->execute($arguments, $gateway);
- },
- ));
- }
-
- foreach ($this->resources as $entry) {
- $handler = $entry['handler'];
- $registry->registerResource($entry['definition'], $this->boundClosure(
- static fn (array $arguments): mixed => $handler->read(
- $arguments['uri'],
- new ClientGateway($arguments['_session']),
- ),
- ));
- }
-
- foreach ($this->resourceTemplates as $entry) {
- $handler = $entry['handler'];
- $registry->registerResourceTemplate($entry['definition'], $this->boundClosure(
- static function (array $arguments) use ($handler): mixed {
- $gateway = new ClientGateway($arguments['_session']);
- $uri = $arguments['uri'];
- unset($arguments['_session'], $arguments['_request'], $arguments['uri']);
-
- return $handler->read($uri, $arguments, $gateway);
- },
- ), $entry['completionProviders']);
- }
-
- foreach ($this->prompts as $entry) {
- $handler = $entry['handler'];
- $registry->registerPrompt($entry['definition'], $this->boundClosure(
- static function (array $arguments) use ($handler): mixed {
- $gateway = new ClientGateway($arguments['_session']);
- unset($arguments['_session'], $arguments['_request']);
-
- return $handler->get($arguments, $gateway);
- },
- ), $entry['completionProviders']);
- }
- }
-
- private function boundClosure(\Closure $closure): \Closure
- {
- return \Closure::bind($closure, null, ReferenceHandler::class);
- }
-}
diff --git a/src/Capability/Registry/Loader/LoaderInterface.php b/src/Capability/Registry/Loader/LoaderInterface.php
deleted file mode 100644
index a7ad87ce..00000000
--- a/src/Capability/Registry/Loader/LoaderInterface.php
+++ /dev/null
@@ -1,22 +0,0 @@
-
- */
-interface LoaderInterface
-{
- public function load(RegistryInterface $registry): void;
-}
diff --git a/src/Capability/Registry/Loader/ReflectedElementLoader.php b/src/Capability/Registry/Loader/ReflectedElementLoader.php
deleted file mode 100644
index a87dd58b..00000000
--- a/src/Capability/Registry/Loader/ReflectedElementLoader.php
+++ /dev/null
@@ -1,337 +0,0 @@
-
- *
- * @phpstan-import-type Handler from ElementReference
- */
-final class ReflectedElementLoader implements LoaderInterface
-{
- /**
- * @param array{
- * handler: Handler,
- * name: ?string,
- * title: ?string,
- * description: ?string,
- * annotations: ?ToolAnnotations,
- * icons: ?Icon[],
- * meta: ?array,
- * outputSchema: ?array
- * }[] $tools
- * @param array{
- * handler: Handler,
- * uri: string,
- * name: ?string,
- * title: ?string,
- * description: ?string,
- * mimeType: ?string,
- * size: int|null,
- * annotations: ?Annotations,
- * icons: ?Icon[],
- * meta: ?array
- * }[] $resources
- * @param array{
- * handler: Handler,
- * uriTemplate: string,
- * name: ?string,
- * title: ?string,
- * description: ?string,
- * mimeType: ?string,
- * annotations: ?Annotations,
- * meta: ?array
- * }[] $resourceTemplates
- * @param array{
- * handler: Handler,
- * name: ?string,
- * description: ?string,
- * icons: ?Icon[],
- * meta: ?array
- * }[] $prompts
- */
- public function __construct(
- private readonly array $tools = [],
- private readonly array $resources = [],
- private readonly array $resourceTemplates = [],
- private readonly array $prompts = [],
- private LoggerInterface $logger = new NullLogger(),
- private ?SchemaGeneratorInterface $schemaGenerator = null,
- ) {
- }
-
- public function load(RegistryInterface $registry): void
- {
- $docBlockParser = new DocBlockParser(logger: $this->logger);
- $schemaGenerator = $this->schemaGenerator ?? new SchemaGenerator($docBlockParser);
-
- // Register Tools
- foreach ($this->tools as $data) {
- try {
- $reflection = HandlerResolver::resolve($data['handler']);
-
- if ($reflection instanceof \ReflectionFunction) {
- $name = $data['name'] ?? 'closure_tool_'.spl_object_id($data['handler']);
- $description = $data['description'] ?? null;
- } else {
- $classShortName = $reflection->getDeclaringClass()->getShortName();
- $methodName = $reflection->getName();
- $docBlock = $docBlockParser->parseDocBlock($reflection->getDocComment() ?? null);
-
- $name = $data['name'] ?? ('__invoke' === $methodName ? $classShortName : $methodName);
- $description = $data['description'] ?? $docBlockParser->getDescription($docBlock) ?? null;
- }
-
- $inputSchema = $data['inputSchema'] ?? $schemaGenerator->generate($reflection);
-
- $tool = new Tool(
- name: $name,
- title: $data['title'] ?? null,
- inputSchema: $inputSchema,
- description: $description,
- annotations: $data['annotations'] ?? null,
- icons: $data['icons'] ?? null,
- meta: $data['meta'] ?? null,
- outputSchema: $data['outputSchema'] ?? null,
- );
- $registry->registerTool($tool, $data['handler']);
-
- $handlerDesc = $this->getHandlerDescription($data['handler']);
- $this->logger->debug("Registered manual tool {$name} from handler {$handlerDesc}");
- } catch (\Throwable $e) {
- $this->logger->error(
- 'Failed to register manual tool',
- ['handler' => $data['handler'], 'name' => $data['name'], 'exception' => $e],
- );
- throw new ConfigurationException("Error registering manual tool '{$data['name']}': {$e->getMessage()}", 0, $e);
- }
- }
-
- // Register Resources
- foreach ($this->resources as $data) {
- try {
- $reflection = HandlerResolver::resolve($data['handler']);
-
- if ($reflection instanceof \ReflectionFunction) {
- $name = $data['name'] ?? 'closure_resource_'.spl_object_id($data['handler']);
- $description = $data['description'] ?? null;
- } else {
- $classShortName = $reflection->getDeclaringClass()->getShortName();
- $methodName = $reflection->getName();
- $docBlock = $docBlockParser->parseDocBlock($reflection->getDocComment() ?? null);
-
- $name = $data['name'] ?? ('__invoke' === $methodName ? $classShortName : $methodName);
- $description = $data['description'] ?? $docBlockParser->getDescription($docBlock) ?? null;
- }
-
- $resource = new ResourceDefinition(
- uri: $data['uri'],
- name: $name,
- title: $data['title'] ?? null,
- description: $description,
- mimeType: $data['mimeType'] ?? null,
- annotations: $data['annotations'] ?? null,
- size: $data['size'] ?? null,
- icons: $data['icons'] ?? null,
- meta: $data['meta'] ?? null,
- );
- $registry->registerResource($resource, $data['handler']);
-
- $handlerDesc = $this->getHandlerDescription($data['handler']);
- $this->logger->debug("Registered manual resource {$name} from handler {$handlerDesc}");
- } catch (\Throwable $e) {
- $this->logger->error(
- 'Failed to register manual resource',
- ['handler' => $data['handler'], 'uri' => $data['uri'], 'exception' => $e],
- );
- throw new ConfigurationException("Error registering manual resource '{$data['uri']}': {$e->getMessage()}", 0, $e);
- }
- }
-
- // Register Templates
- foreach ($this->resourceTemplates as $data) {
- try {
- $reflection = HandlerResolver::resolve($data['handler']);
-
- if ($reflection instanceof \ReflectionFunction) {
- $name = $data['name'] ?? 'closure_template_'.spl_object_id($data['handler']);
- $description = $data['description'] ?? null;
- } else {
- $classShortName = $reflection->getDeclaringClass()->getShortName();
- $methodName = $reflection->getName();
- $docBlock = $docBlockParser->parseDocBlock($reflection->getDocComment() ?? null);
-
- $name = $data['name'] ?? ('__invoke' === $methodName ? $classShortName : $methodName);
- $description = $data['description'] ?? $docBlockParser->getDescription($docBlock) ?? null;
- }
-
- $template = new ResourceTemplate(
- uriTemplate: $data['uriTemplate'],
- name: $name,
- title: $data['title'] ?? null,
- description: $description,
- mimeType: $data['mimeType'] ?? null,
- annotations: $data['annotations'] ?? null,
- meta: $data['meta'] ?? null,
- );
- $completionProviders = $this->getCompletionProviders($reflection);
- $registry->registerResourceTemplate($template, $data['handler'], $completionProviders);
-
- $handlerDesc = $this->getHandlerDescription($data['handler']);
- $this->logger->debug("Registered manual template {$name} from handler {$handlerDesc}");
- } catch (\Throwable $e) {
- $this->logger->error(
- 'Failed to register manual template',
- ['handler' => $data['handler'], 'uriTemplate' => $data['uriTemplate'], 'exception' => $e],
- );
- throw new ConfigurationException("Error registering manual resource template '{$data['uriTemplate']}': {$e->getMessage()}", 0, $e);
- }
- }
-
- // Register Prompts
- foreach ($this->prompts as $data) {
- try {
- $reflection = HandlerResolver::resolve($data['handler']);
-
- if ($reflection instanceof \ReflectionFunction) {
- $name = $data['name'] ?? 'closure_prompt_'.spl_object_id($data['handler']);
- $description = $data['description'] ?? null;
- } else {
- $classShortName = $reflection->getDeclaringClass()->getShortName();
- $methodName = $reflection->getName();
- $docBlock = $docBlockParser->parseDocBlock($reflection->getDocComment() ?? null);
-
- $name = $data['name'] ?? ('__invoke' === $methodName ? $classShortName : $methodName);
- $description = $data['description'] ?? $docBlockParser->getDescription($docBlock) ?? null;
- }
-
- $arguments = [];
- $paramTags = $reflection instanceof \ReflectionMethod ? $docBlockParser->getParamTags(
- $docBlockParser->parseDocBlock($reflection->getDocComment() ?? null),
- ) : [];
- foreach ($reflection->getParameters() as $param) {
- $reflectionType = $param->getType();
-
- // Basic DI check (heuristic)
- if ($reflectionType instanceof \ReflectionNamedType && !$reflectionType->isBuiltin()) {
- continue;
- }
-
- $paramTag = $paramTags['$'.$param->getName()] ?? null;
- $arguments[] = new PromptArgument(
- $param->getName(),
- $paramTag ? trim((string) $paramTag->getDescription()) : null,
- !$param->isOptional() && !$param->isDefaultValueAvailable(),
- );
- }
- $prompt = new Prompt(
- name: $name,
- title: $data['title'] ?? null,
- description: $description,
- arguments: $arguments,
- icons: $data['icons'] ?? null,
- meta: $data['meta'] ?? null
- );
- $completionProviders = $this->getCompletionProviders($reflection);
- $registry->registerPrompt($prompt, $data['handler'], $completionProviders);
-
- $handlerDesc = $this->getHandlerDescription($data['handler']);
- $this->logger->debug("Registered manual prompt {$name} from handler {$handlerDesc}");
- } catch (\Throwable $e) {
- $this->logger->error(
- 'Failed to register manual prompt',
- ['handler' => $data['handler'], 'name' => $data['name'], 'exception' => $e],
- );
- throw new ConfigurationException("Error registering manual prompt '{$data['name']}': {$e->getMessage()}", 0, $e);
- }
- }
-
- $this->logger->debug('Manual element registration complete.');
- }
-
- /**
- * @param Handler $handler
- */
- private function getHandlerDescription(\Closure|array|string $handler): string
- {
- if ($handler instanceof \Closure) {
- return 'Closure';
- }
-
- if (\is_array($handler)) {
- return \sprintf(
- '%s::%s',
- \is_object($handler[0]) ? $handler[0]::class : $handler[0],
- $handler[1],
- );
- }
-
- return (string) $handler;
- }
-
- /**
- * @return array
- */
- private function getCompletionProviders(\ReflectionMethod|\ReflectionFunction $reflection): array
- {
- $completionProviders = [];
- foreach ($reflection->getParameters() as $param) {
- $reflectionType = $param->getType();
- if ($reflectionType instanceof \ReflectionNamedType && !$reflectionType->isBuiltin()) {
- continue;
- }
-
- $completionAttributes = $param->getAttributes(
- CompletionProvider::class,
- \ReflectionAttribute::IS_INSTANCEOF,
- );
- if (!empty($completionAttributes)) {
- $attributeInstance = $completionAttributes[0]->newInstance();
-
- if ($attributeInstance->provider) {
- $completionProviders[$param->getName()] = $attributeInstance->provider;
- } elseif ($attributeInstance->providerClass) {
- $completionProviders[$param->getName()] = $attributeInstance->providerClass;
- } elseif ($attributeInstance->values) {
- $completionProviders[$param->getName()] = new ListCompletionProvider($attributeInstance->values);
- } elseif ($attributeInstance->enum) {
- $completionProviders[$param->getName()] = new EnumCompletionProvider($attributeInstance->enum);
- }
- }
- }
-
- return $completionProviders;
- }
-}
diff --git a/src/Capability/Registry/PromptReference.php b/src/Capability/Registry/PromptReference.php
deleted file mode 100644
index ec7d0219..00000000
--- a/src/Capability/Registry/PromptReference.php
+++ /dev/null
@@ -1,51 +0,0 @@
-
- */
-class PromptReference extends ElementReference
-{
- /**
- * @param Handler $handler
- * @param array $completionProviders
- */
- public function __construct(
- public readonly Prompt $prompt,
- \Closure|array|string $handler,
- public readonly array $completionProviders = [],
- ) {
- parent::__construct($handler);
- }
-
- /**
- * Formats the raw result of a prompt generator into an array of MCP PromptMessages.
- *
- * @param mixed $promptGenerationResult expected: array of message structures
- *
- * @return PromptMessage[] array of PromptMessage objects
- *
- * @throws \RuntimeException if the result cannot be formatted
- * @throws \JsonException if JSON encoding fails
- */
- public function formatResult(mixed $promptGenerationResult): array
- {
- return (new PromptResultFormatter())->format($promptGenerationResult);
- }
-}
diff --git a/src/Capability/Registry/ReferenceHandler.php b/src/Capability/Registry/ReferenceHandler.php
deleted file mode 100644
index 99e58442..00000000
--- a/src/Capability/Registry/ReferenceHandler.php
+++ /dev/null
@@ -1,297 +0,0 @@
-
- */
-final class ReferenceHandler implements ReferenceHandlerInterface
-{
- public function __construct(
- private readonly ?ContainerInterface $container = null,
- ) {
- }
-
- /**
- * @param array $arguments
- */
- public function handle(ElementReference $reference, array $arguments): mixed
- {
- // Closures bound to this class as their scope consume the raw argument bag
- // directly. Used by ExplicitElementLoader so reflection + name-based parameter
- // mapping is bypassed for explicitly registered handler interfaces.
- if ($reference->handler instanceof \Closure
- && self::class === (new \ReflectionFunction($reference->handler))->getClosureScopeClass()?->getName()
- ) {
- return ($reference->handler)($arguments);
- }
-
- $session = $arguments['_session'];
-
- if (\is_string($reference->handler)) {
- if (class_exists($reference->handler) && method_exists($reference->handler, '__invoke')) {
- $reflection = new \ReflectionMethod($reference->handler, '__invoke');
- $instance = $this->getClassInstance($reference->handler);
- $arguments = $this->prepareArguments($reflection, $arguments);
-
- return \call_user_func($instance, ...$arguments);
- }
-
- if (\function_exists($reference->handler)) {
- $reflection = new \ReflectionFunction($reference->handler);
- $arguments = $this->prepareArguments($reflection, $arguments);
-
- return \call_user_func($reference->handler, ...$arguments);
- }
- }
-
- if (\is_callable($reference->handler)) {
- $reflection = $this->getReflectionForCallable($reference->handler, $session);
- $arguments = $this->prepareArguments($reflection, $arguments);
-
- return \call_user_func($reference->handler, ...$arguments);
- }
-
- if (\is_array($reference->handler)) {
- [$className, $methodName] = $reference->handler;
- $reflection = new \ReflectionMethod($className, $methodName);
- $instance = $this->getClassInstance($className);
- $arguments = $this->prepareArguments($reflection, $arguments);
-
- return \call_user_func([$instance, $methodName], ...$arguments);
- }
-
- throw new InvalidArgumentException('Invalid handler type');
- }
-
- private function getClassInstance(string $className): object
- {
- if (null !== $this->container && $this->container->has($className)) {
- return $this->container->get($className);
- }
-
- return new $className();
- }
-
- /**
- * @param array $arguments
- *
- * @return array
- */
- private function prepareArguments(\ReflectionFunctionAbstract $reflection, array $arguments): array
- {
- $finalArgs = [];
-
- foreach ($reflection->getParameters() as $parameter) {
- // TODO: Handle variadic parameters.
- $paramName = $parameter->getName();
- $paramPosition = $parameter->getPosition();
-
- // Check if parameter is a special injectable type
- $type = $parameter->getType();
- if ($type instanceof \ReflectionNamedType && !$type->isBuiltin()) {
- $typeName = $type->getName();
-
- if (RequestContext::class === $typeName && isset($arguments['_session'], $arguments['_request'])) {
- $finalArgs[$paramPosition] = new RequestContext($arguments['_session'], $arguments['_request']);
- continue;
- }
-
- if (ClientGateway::class === $typeName && isset($arguments['_session'])) {
- $finalArgs[$paramPosition] = new ClientGateway($arguments['_session']);
- continue;
- }
- }
-
- if (isset($arguments[$paramName])) {
- $argument = $arguments[$paramName];
- try {
- $finalArgs[$paramPosition] = $this->castArgumentType($argument, $parameter);
- } catch (InvalidArgumentException $e) {
- throw RegistryException::invalidParams($e->getMessage(), $e);
- } catch (\Throwable $e) {
- throw RegistryException::internalError("Error processing parameter `{$paramName}`: {$e->getMessage()}", $e);
- }
- } elseif ($parameter->isDefaultValueAvailable()) {
- $finalArgs[$paramPosition] = $parameter->getDefaultValue();
- } elseif ($parameter->allowsNull()) {
- $finalArgs[$paramPosition] = null;
- } elseif ($parameter->isOptional()) {
- continue;
- } else {
- $reflectionName = $reflection instanceof \ReflectionMethod
- ? $reflection->class.'::'.$reflection->name
- : 'Closure';
- throw RegistryException::internalError("Missing required argument `{$paramName}` for {$reflectionName}.");
- }
- }
-
- return array_values($finalArgs);
- }
-
- /**
- * Gets a ReflectionMethod or ReflectionFunction for a callable.
- */
- private function getReflectionForCallable(callable $handler, SessionInterface $session): \ReflectionMethod|\ReflectionFunction
- {
- if (\is_string($handler)) {
- return new \ReflectionFunction($handler);
- }
-
- if ($handler instanceof \Closure) {
- return new \ReflectionFunction($handler);
- }
-
- if (\is_array($handler) && 2 === \count($handler)) {
- [$class, $method] = $handler;
-
- return new \ReflectionMethod($class, $method);
- }
-
- throw new InvalidArgumentException('Cannot create reflection for this callable type');
- }
-
- /**
- * Attempts type casting based on ReflectionParameter type hints.
- *
- * @throws InvalidArgumentException if casting is impossible for the required type
- */
- private function castArgumentType(mixed $argument, \ReflectionParameter $parameter): mixed
- {
- $type = $parameter->getType();
-
- if (null === $argument) {
- if ($type && $type->allowsNull()) {
- return null;
- }
- }
-
- if (!$type instanceof \ReflectionNamedType) {
- return $argument;
- }
-
- $typeName = $type->getName();
-
- if (enum_exists($typeName)) {
- if (\is_object($argument) && $argument instanceof $typeName) {
- return $argument;
- }
-
- if (is_subclass_of($typeName, \BackedEnum::class)) {
- $value = $typeName::tryFrom($argument);
- if (null === $value) {
- throw new InvalidArgumentException("Invalid value '{$argument}' for backed enum {$typeName}. Expected one of its backing values.");
- }
-
- return $value;
- }
- if (\is_string($argument)) {
- foreach ($typeName::cases() as $case) {
- if ($case->name === $argument) {
- return $case;
- }
- }
- $validNames = array_map(static fn ($c) => $c->name, $typeName::cases());
- throw new InvalidArgumentException("Invalid value '{$argument}' for unit enum {$typeName}. Expected one of: ".implode(', ', $validNames).'.');
- }
- throw new InvalidArgumentException("Invalid value type '{$argument}' for unit enum {$typeName}. Expected a string matching a case name.");
- }
-
- try {
- return match (strtolower($typeName)) {
- 'int', 'integer' => $this->castToInt($argument),
- 'string' => (string) $argument,
- 'bool', 'boolean' => $this->castToBoolean($argument),
- 'float', 'double' => $this->castToFloat($argument),
- 'array' => $this->castToArray($argument),
- default => $argument,
- };
- } catch (\TypeError $e) {
- throw new InvalidArgumentException("Value cannot be cast to required type `{$typeName}`.", 0, $e);
- }
- }
-
- /**
- * Helper to cast strictly to boolean.
- */
- private function castToBoolean(mixed $argument): bool
- {
- if (\is_bool($argument)) {
- return $argument;
- }
- if (1 === $argument || '1' === $argument || 'true' === strtolower((string) $argument)) {
- return true;
- }
- if (0 === $argument || '0' === $argument || 'false' === strtolower((string) $argument)) {
- return false;
- }
-
- throw new InvalidArgumentException('Cannot cast value to boolean. Use true/false/1/0.');
- }
-
- /**
- * Helper to cast strictly to integer.
- */
- private function castToInt(mixed $argument): int
- {
- if (\is_int($argument)) {
- return $argument;
- }
- if (is_numeric($argument) && floor((float) $argument) == $argument && !\is_string($argument)) {
- return (int) $argument;
- }
- if (\is_string($argument) && ctype_digit(ltrim($argument, '-'))) {
- return (int) $argument;
- }
-
- throw new InvalidArgumentException('Cannot cast value to integer. Expected integer representation.');
- }
-
- /**
- * Helper to cast strictly to float.
- */
- private function castToFloat(mixed $argument): float
- {
- if (\is_float($argument)) {
- return $argument;
- }
- if (\is_int($argument)) {
- return (float) $argument;
- }
- if (is_numeric($argument)) {
- return (float) $argument;
- }
-
- throw new InvalidArgumentException('Cannot cast value to float. Expected numeric representation.');
- }
-
- /**
- * Helper to cast strictly to array.
- *
- * @return array
- */
- private function castToArray(mixed $argument): array
- {
- if (\is_array($argument)) {
- return $argument;
- }
-
- throw new InvalidArgumentException('Cannot cast value to array. Expected array.');
- }
-}
diff --git a/src/Capability/Registry/ReferenceHandlerInterface.php b/src/Capability/Registry/ReferenceHandlerInterface.php
deleted file mode 100644
index c4e52f10..00000000
--- a/src/Capability/Registry/ReferenceHandlerInterface.php
+++ /dev/null
@@ -1,37 +0,0 @@
-
- */
-interface ReferenceHandlerInterface
-{
- /**
- * Handles execution of an MCP element reference.
- *
- * @param ElementReference $reference the element reference to execute
- * @param array $arguments arguments to pass to the handler
- *
- * @return mixed the result of the element execution
- *
- * @throws InvalidArgumentException if the handler is invalid
- * @throws RegistryException if execution fails
- */
- public function handle(ElementReference $reference, array $arguments): mixed;
-}
diff --git a/src/Capability/Registry/ResourceReference.php b/src/Capability/Registry/ResourceReference.php
deleted file mode 100644
index 10d78377..00000000
--- a/src/Capability/Registry/ResourceReference.php
+++ /dev/null
@@ -1,59 +0,0 @@
-
- */
-class ResourceReference extends ElementReference
-{
- /**
- * @param Handler $handler
- */
- public function __construct(
- public readonly ResourceDefinition $resource,
- callable|array|string $handler,
- ) {
- parent::__construct($handler);
- }
-
- /**
- * Formats the raw result of a resource read operation into MCP ResourceContent items.
- *
- * @param mixed $readResult the raw result from the resource handler method
- * @param string $uri the URI of the resource that was read
- * @param ?string $mimeType the MIME type from the ResourceDefinition
- *
- * @return ResourceContents[] array of ResourceContents objects
- *
- * Supported result types:
- * - ResourceContents: Used as-is
- * - EmbeddedResource: Resource is extracted from the EmbeddedResource
- * - string: Converted to text content with guessed or provided MIME type
- * - stream resource: Read and converted to blob with provided MIME type
- * - array with 'blob' key: Used as blob content
- * - array with 'text' key: Used as text content
- * - SplFileInfo: Read and converted to blob
- * - array: Converted to JSON if MIME type is application/json or contains 'json'
- * For other MIME types, will try to convert to JSON with a warning
- */
- public function formatResult(mixed $readResult, string $uri, ?string $mimeType = null): array
- {
- return (new ResourceResultFormatter())->format($readResult, $uri, $mimeType, $this->resource->meta);
- }
-}
diff --git a/src/Capability/Registry/ResourceTemplateReference.php b/src/Capability/Registry/ResourceTemplateReference.php
deleted file mode 100644
index 49d03b39..00000000
--- a/src/Capability/Registry/ResourceTemplateReference.php
+++ /dev/null
@@ -1,112 +0,0 @@
-
- */
-class ResourceTemplateReference extends ElementReference
-{
- /**
- * @var array
- */
- private array $variableNames;
-
- private string $uriTemplateRegex;
-
- /**
- * @param Handler $handler
- * @param array $completionProviders
- */
- public function __construct(
- public readonly ResourceTemplate $resourceTemplate,
- callable|array|string $handler,
- public readonly array $completionProviders = [],
- ) {
- parent::__construct($handler);
-
- $this->compileTemplate();
- }
-
- /**
- * @return array
- */
- public function getVariableNames(): array
- {
- return $this->variableNames;
- }
-
- public function matches(string $uri): bool
- {
- return 1 === preg_match($this->uriTemplateRegex, $uri);
- }
-
- /** @return array */
- public function extractVariables(string $uri): array
- {
- $matches = [];
-
- preg_match($this->uriTemplateRegex, $uri, $matches);
-
- return array_filter($matches, fn ($key) => \in_array($key, $this->variableNames), \ARRAY_FILTER_USE_KEY);
- }
-
- /**
- * Formats the raw result of a resource read operation into MCP ResourceContent items.
- *
- * @param mixed $readResult the raw result from the resource handler method
- * @param string $uri the URI of the resource that was read
- *
- * @return array array of ResourceContents objects
- *
- * Supported result types:
- * - ResourceContents: Used as-is
- * - EmbeddedResource: Resource is extracted from the EmbeddedResource
- * - string: Converted to text content with guessed or provided MIME type
- * - stream resource: Read and converted to blob with provided MIME type
- * - array with 'blob' key: Used as blob content
- * - array with 'text' key: Used as text content
- * - SplFileInfo: Read and converted to blob
- * - array: Converted to JSON if MIME type is application/json or contains 'json'
- * For other MIME types, will try to convert to JSON with a warning
- */
- public function formatResult(mixed $readResult, string $uri, ?string $mimeType = null): array
- {
- return (new ResourceResultFormatter())->format($readResult, $uri, $mimeType, $this->resourceTemplate->meta);
- }
-
- private function compileTemplate(): void
- {
- $this->variableNames = [];
- $regexParts = [];
-
- $segments = preg_split('/(\{\w+\})/', $this->resourceTemplate->uriTemplate, -1, \PREG_SPLIT_DELIM_CAPTURE | \PREG_SPLIT_NO_EMPTY);
-
- foreach ($segments as $segment) {
- if (preg_match('/^\{(\w+)\}$/', $segment, $matches)) {
- $varName = $matches[1];
- $this->variableNames[] = $varName;
- $regexParts[] = '(?P<'.$varName.'>[^/]+)';
- } else {
- $regexParts[] = preg_quote($segment, '#');
- }
- }
-
- $this->uriTemplateRegex = '#^'.implode('', $regexParts).'$#';
- }
-}
diff --git a/src/Capability/Registry/ToolReference.php b/src/Capability/Registry/ToolReference.php
deleted file mode 100644
index 04316877..00000000
--- a/src/Capability/Registry/ToolReference.php
+++ /dev/null
@@ -1,130 +0,0 @@
-
- */
-class ToolReference extends ElementReference
-{
- /**
- * @param Handler $handler
- */
- public function __construct(
- public readonly Tool $tool,
- callable|array|string $handler,
- ) {
- parent::__construct($handler);
- }
-
- /**
- * Formats the result of a tool execution into an array of MCP Content items.
- *
- * - If the result is already a Content object, it's wrapped in an array.
- * - If the result is an array:
- * - If all elements are Content objects, the array is returned as is.
- * - If it's a mixed array (Content and non-Content items), non-Content items are
- * individually formatted (scalars to TextContent, others to JSON TextContent).
- * - If it's an array with no Content items, the entire array is JSON-encoded into a single TextContent.
- * - Scalars (string, int, float, bool) are wrapped in TextContent.
- * - null is represented as TextContent('(null)').
- * - Other objects are JSON-encoded and wrapped in TextContent.
- *
- * @param mixed $toolExecutionResult the raw value returned by the tool's PHP method
- *
- * @return Content[] the content items for CallToolResult
- *
- * @throws \JsonException if JSON encoding fails for non-Content array/object results
- */
- public function formatResult(mixed $toolExecutionResult): array
- {
- return (new ToolResultFormatter())->format($toolExecutionResult);
- }
-
- /**
- * Extracts structured content from a tool result using the output schema.
- *
- * What may be sent as `structuredContent` depends on the protocol revision in
- * use. Up to `2025-11-25` it has to be a JSON object, and `outputSchema` is
- * restricted to `type: "object"` to match. From `2026-07-28` on (SEP-2106)
- * `outputSchema` is any JSON Schema 2020-12 and `structuredContent` is any JSON
- * value conforming to it — a list included.
- *
- * @param mixed $toolExecutionResult the raw value returned by the tool's PHP method
- * @param ?ProtocolVersion $protocolVersion revision the result is produced for; defaults to the
- * newest handshake revision, whose stricter rule is what
- * every revision reachable through `initialize` requires
- *
- * @return array|null the structured content, or null if not extractable
- *
- * @throws \JsonException if JSON encoding fails for non-Content array/object results
- */
- public function extractStructuredContent(mixed $toolExecutionResult, ?ProtocolVersion $protocolVersion = null): ?array
- {
- $objectOnly = ($protocolVersion ?? ProtocolVersion::latestHandshake())->requiresObjectStructuredContent();
-
- if (\is_array($toolExecutionResult)) {
- // A PHP list serializes to a JSON array, which the revisions predating
- // SEP-2106 do not allow as `structuredContent` — strict clients reject
- // the whole tool call when one is sent.
- if ($objectOnly && array_is_list($toolExecutionResult)) {
- return null;
- }
-
- foreach ($toolExecutionResult as $item) {
- if ($item instanceof Content) {
- // Content items are already reflected in the result's `content`
- // array; an array holding one or more of them isn't structured
- // data. This holds in every revision — it is a duplication rule,
- // not a shape rule.
- return null;
- }
- }
-
- return $toolExecutionResult;
- }
-
- if (\is_object($toolExecutionResult) && !($toolExecutionResult instanceof Content)) {
- $jsonResult = json_encode(
- $toolExecutionResult,
- \JSON_PRETTY_PRINT | \JSON_UNESCAPED_SLASHES | \JSON_UNESCAPED_UNICODE | \JSON_THROW_ON_ERROR | \JSON_INVALID_UTF8_SUBSTITUTE
- );
-
- $decoded = json_decode(
- $jsonResult, true, 512, \JSON_THROW_ON_ERROR
- );
-
- // A plain object always encodes to a JSON object, but `JsonSerializable`
- // can hand back anything. A scalar is dropped whatever the revision
- // allows: `CallToolResult::$structuredContent` is typed `?array` and
- // cannot carry one.
- if (!\is_array($decoded)) {
- return null;
- }
-
- if ($objectOnly && array_is_list($decoded)) {
- return null;
- }
-
- return $decoded;
- }
-
- return null;
- }
-}
diff --git a/src/Capability/RegistryInterface.php b/src/Capability/RegistryInterface.php
deleted file mode 100644
index bbbef766..00000000
--- a/src/Capability/RegistryInterface.php
+++ /dev/null
@@ -1,175 +0,0 @@
-
- * @author Christopher Hertel
- */
-interface RegistryInterface
-{
- /**
- * Registers a tool with its handler. Overwrites any prior registration of the same name.
- * Returns the stored reference, whose identity callers may track to detect later overwrites.
- *
- * @param Handler $handler
- */
- public function registerTool(Tool $tool, callable|array|string $handler): ToolReference;
-
- /**
- * Registers a resource with its handler. Overwrites any prior registration of the same URI.
- * Returns the stored reference, whose identity callers may track to detect later overwrites.
- *
- * @param Handler $handler
- */
- public function registerResource(ResourceDefinition $resource, callable|array|string $handler): ResourceReference;
-
- /**
- * Registers a resource template with its handler and completion providers.
- * Overwrites any prior registration of the same URI template.
- * Returns the stored reference, whose identity callers may track to detect later overwrites.
- *
- * @param Handler $handler
- * @param array $completionProviders
- */
- public function registerResourceTemplate(
- ResourceTemplate $template,
- callable|array|string $handler,
- array $completionProviders = [],
- ): ResourceTemplateReference;
-
- /**
- * Registers a prompt with its handler and completion providers.
- * Overwrites any prior registration of the same name.
- * Returns the stored reference, whose identity callers may track to detect later overwrites.
- *
- * @param Handler $handler
- * @param array $completionProviders
- */
- public function registerPrompt(
- Prompt $prompt,
- callable|array|string $handler,
- array $completionProviders = [],
- ): PromptReference;
-
- /**
- * Removes a tool by name. No-op if absent.
- */
- public function unregisterTool(string $name): void;
-
- /**
- * Removes a resource by URI. No-op if absent.
- */
- public function unregisterResource(string $uri): void;
-
- /**
- * Removes a resource template by URI template. No-op if absent.
- */
- public function unregisterResourceTemplate(string $uriTemplate): void;
-
- /**
- * Removes a prompt by name. No-op if absent.
- */
- public function unregisterPrompt(string $name): void;
-
- public function hasTool(string $name): bool;
-
- public function hasResource(string $uri): bool;
-
- public function hasResourceTemplate(string $uriTemplate): bool;
-
- public function hasPrompt(string $name): bool;
-
- /**
- * @return bool true if any tools are registered
- */
- public function hasTools(): bool;
-
- /**
- * Gets all registered tools.
- */
- public function getTools(?int $limit = null, ?string $cursor = null): Page;
-
- /**
- * Gets a tool reference by name.
- *
- * @throws ToolNotFoundException
- */
- public function getTool(string $name): ToolReference;
-
- /**
- * @return bool true if any resources are registered
- */
- public function hasResources(): bool;
-
- /**
- * Gets all registered resources.
- */
- public function getResources(?int $limit = null, ?string $cursor = null): Page;
-
- /**
- * Gets a resource reference by URI (includes template matching if enabled).
- *
- * @throws ResourceNotFoundException
- */
- public function getResource(string $uri, bool $includeTemplates = true): ResourceReference|ResourceTemplateReference;
-
- /**
- * @return bool true if any resource templates are registered
- */
- public function hasResourceTemplates(): bool;
-
- /**
- * Gets all registered resource templates.
- */
- public function getResourceTemplates(?int $limit = null, ?string $cursor = null): Page;
-
- /**
- * Gets a resource template reference by URI template.
- *
- * @throws ResourceNotFoundException
- */
- public function getResourceTemplate(string $uriTemplate): ResourceTemplateReference;
-
- /**
- * @return bool true if any prompts are registered
- */
- public function hasPrompts(): bool;
-
- /**
- * Gets all registered prompts.
- */
- public function getPrompts(?int $limit = null, ?string $cursor = null): Page;
-
- /**
- * Gets a prompt reference by name.
- *
- * @throws PromptNotFoundException
- */
- public function getPrompt(string $name): PromptReference;
-}
diff --git a/src/Capability/Tool/NameValidator.php b/src/Capability/Tool/NameValidator.php
deleted file mode 100644
index fe9bb084..00000000
--- a/src/Capability/Tool/NameValidator.php
+++ /dev/null
@@ -1,20 +0,0 @@
-
- */
-class Client
-{
- private const RETRY_BASE_DELAY_MS = 100;
-
- private ?TransportInterface $transport = null;
-
- public function __construct(
- private readonly Protocol $protocol,
- private readonly Configuration $config,
- private readonly LoggerInterface $logger = new NullLogger(),
- ) {
- }
-
- /**
- * Create a new client builder for fluent configuration.
- */
- public static function builder(): Builder
- {
- return new Builder();
- }
-
- /**
- * Connect to an MCP server using the provided transport.
- *
- * A failed attempt is closed and retried, see {@see Builder::setMaxRetries()}.
- *
- * @throws ConnectionException If connection or initialization fails on every attempt
- */
- public function connect(TransportInterface $transport): void
- {
- $this->transport = $transport;
- $this->protocol->connect($transport, $this->config);
-
- $maxAttempts = $this->config->maxRetries + 1;
-
- for ($attempt = 1; $attempt <= $maxAttempts; ++$attempt) {
- try {
- $transport->connect();
-
- $this->logger->info('Client connected and initialized', ['attempt' => $attempt]);
-
- return;
- } catch (ConnectionException $e) {
- // initialize() flags the session before sending the initialized
- // notification, so a failure in between leaves the flag set.
- $this->protocol->getState()->setInitialized(false);
-
- $transport->close();
-
- if ($attempt === $maxAttempts) {
- throw $e;
- }
-
- $this->logger->warning('Connection attempt failed, retrying', [
- 'attempt' => $attempt,
- 'max_attempts' => $maxAttempts,
- 'exception' => $e,
- ]);
-
- usleep($attempt * self::RETRY_BASE_DELAY_MS * 1000);
- }
- }
- }
-
- /**
- * Check if connected and initialized.
- */
- public function isConnected(): bool
- {
- return null !== $this->transport && $this->protocol->getState()->isInitialized();
- }
-
- /**
- * Get server information from initialization.
- */
- public function getServerInfo(): ?Implementation
- {
- return $this->protocol->getState()->getServerInfo();
- }
-
- /**
- * Get server instructions.
- */
- public function getInstructions(): ?string
- {
- return $this->protocol->getState()->getInstructions();
- }
-
- /**
- * Protocol revision negotiated during the handshake.
- *
- * This is the version the server answered with, which is not necessarily the
- * one configured on the builder: a server that cannot speak the requested
- * revision counter-offers one it supports. Null until the handshake completed.
- */
- public function getProtocolVersion(): ?ProtocolVersion
- {
- return $this->protocol->getState()->getProtocolVersion();
- }
-
- /**
- * Send a ping request to the server.
- */
- public function ping(): void
- {
- $request = new PingRequest();
-
- $this->sendRequest($request);
- }
-
- /**
- * List available tools from the server.
- */
- public function listTools(?string $cursor = null): ListToolsResult
- {
- $request = new ListToolsRequest($cursor);
-
- $response = $this->sendRequest($request);
-
- return ListToolsResult::fromArray($response->result);
- }
-
- /**
- * Call a tool on the server.
- *
- * @param string $name Tool name
- * @param array $arguments Tool arguments
- * @param (callable(float $progress, ?float $total, ?string $message): void)|null $onProgress
- * Optional callback for progress updates
- */
- public function callTool(string $name, array $arguments = [], ?callable $onProgress = null): CallToolResult
- {
- $request = new CallToolRequest($name, $arguments);
-
- $response = $this->sendRequest($request, $onProgress);
-
- return CallToolResult::fromArray($response->result);
- }
-
- /**
- * List available resources from the server.
- */
- public function listResources(?string $cursor = null): ListResourcesResult
- {
- $request = new ListResourcesRequest($cursor);
-
- $response = $this->sendRequest($request);
-
- return ListResourcesResult::fromArray($response->result);
- }
-
- /**
- * List available resource templates from the server.
- */
- public function listResourceTemplates(?string $cursor = null): ListResourceTemplatesResult
- {
- $request = new ListResourceTemplatesRequest($cursor);
-
- $response = $this->sendRequest($request);
-
- return ListResourceTemplatesResult::fromArray($response->result);
- }
-
- /**
- * Read a resource by URI.
- *
- * @param string $uri The resource URI
- * @param (callable(float $progress, ?float $total, ?string $message): void)|null $onProgress
- * Optional callback for progress updates
- */
- public function readResource(string $uri, ?callable $onProgress = null): ReadResourceResult
- {
- $request = new ReadResourceRequest($uri);
-
- $response = $this->sendRequest($request, $onProgress);
-
- return ReadResourceResult::fromArray($response->result);
- }
-
- /**
- * List available prompts from the server.
- */
- public function listPrompts(?string $cursor = null): ListPromptsResult
- {
- $request = new ListPromptsRequest($cursor);
-
- $response = $this->sendRequest($request);
-
- return ListPromptsResult::fromArray($response->result);
- }
-
- /**
- * Get a prompt from the server.
- *
- * @param string $name Prompt name
- * @param array $arguments Prompt arguments
- * @param (callable(float $progress, ?float $total, ?string $message): void)|null $onProgress
- * Optional callback for progress updates
- */
- public function getPrompt(string $name, array $arguments = [], ?callable $onProgress = null): GetPromptResult
- {
- $request = new GetPromptRequest($name, $arguments);
-
- $response = $this->sendRequest($request, $onProgress);
-
- return GetPromptResult::fromArray($response->result);
- }
-
- /**
- * Request completion suggestions for a prompt or resource argument.
- *
- * @param PromptReference|ResourceReference $ref The prompt or resource reference
- * @param array{name: string, value: string} $argument The argument to complete
- */
- public function complete(PromptReference|ResourceReference $ref, array $argument): CompletionCompleteResult
- {
- $request = new CompletionCompleteRequest($ref, $argument);
-
- $response = $this->sendRequest($request);
-
- return CompletionCompleteResult::fromArray($response->result);
- }
-
- /**
- * Set the minimum logging level for server log messages.
- */
- public function setLoggingLevel(LoggingLevel $level): void
- {
- $request = new SetLogLevelRequest($level);
-
- $this->sendRequest($request);
- }
-
- /**
- * Notify the server that the client's list of roots has changed.
- *
- * The server should react by requesting an updated list via roots/list.
- *
- * @throws RuntimeException if the client did not advertise the `roots.listChanged` capability
- * @throws ConnectionException if the client is not connected
- */
- public function sendRootsListChanged(): void
- {
- if (true !== $this->config->capabilities->rootsListChanged) {
- throw new RuntimeException('Cannot send a "roots/list_changed" notification without advertising the "roots.listChanged" capability. Build the client with new ClientCapabilities(roots: true, rootsListChanged: true).');
- }
-
- if (!$this->isConnected()) {
- throw new ConnectionException('Client is not connected. Call connect() first.');
- }
-
- $this->protocol->sendNotification(new RootsListChangedNotification());
- }
-
- /**
- * Send a request to the server and wait for response.
- *
- * @param (callable(float $progress, ?float $total, ?string $message): void)|null $onProgress
- *
- * @return Response
- *
- * @throws RequestException|ConnectionException
- */
- private function sendRequest(Request $request, ?callable $onProgress = null): Response
- {
- if (!$this->isConnected()) {
- throw new ConnectionException('Client is not connected. Call connect() first.');
- }
-
- $withProgress = null !== $onProgress;
- $fiber = new \Fiber(fn () => $this->protocol->request($request, $this->config->requestTimeout, $withProgress));
- $response = $this->transport->runRequest($fiber, $onProgress);
-
- if ($response instanceof Error) {
- throw RequestException::fromError($response);
- }
-
- return $response;
- }
-
- /**
- * Disconnect from the server.
- */
- public function disconnect(): void
- {
- if (null !== $this->transport) {
- $this->transport->close();
- $this->transport = null;
- $this->logger->info('Client disconnected');
- }
- }
-}
diff --git a/src/Client/Builder.php b/src/Client/Builder.php
deleted file mode 100644
index 9a69da17..00000000
--- a/src/Client/Builder.php
+++ /dev/null
@@ -1,174 +0,0 @@
-
- */
-final class Builder
-{
- private string $name = 'mcp-php-client';
- private string $version = '1.0.0';
- private ?string $description = null;
- private ?ProtocolVersion $protocolVersion = null;
- private ?ClientCapabilities $capabilities = null;
- private int $initTimeout = 30;
- private int $requestTimeout = 120;
- private int $maxRetries = 3;
- private ?LoggerInterface $logger = null;
-
- /** @var NotificationHandlerInterface[] */
- private array $notificationHandlers = [];
-
- /** @var RequestHandlerInterface[] */
- private array $requestHandlers = [];
-
- /**
- * Set the client name and version.
- */
- public function setClientInfo(string $name, string $version, ?string $description = null): self
- {
- $this->name = $name;
- $this->version = $version;
- $this->description = $description;
-
- return $this;
- }
-
- /**
- * Set the protocol version to use.
- */
- public function setProtocolVersion(ProtocolVersion $protocolVersion): self
- {
- $this->protocolVersion = $protocolVersion;
-
- return $this;
- }
-
- /**
- * Set client capabilities.
- */
- public function setCapabilities(ClientCapabilities $capabilities): self
- {
- $this->capabilities = $capabilities;
-
- return $this;
- }
-
- /**
- * Set initialization timeout in seconds.
- */
- public function setInitTimeout(int $seconds): self
- {
- $this->initTimeout = $seconds;
-
- return $this;
- }
-
- /**
- * Set request timeout in seconds.
- */
- public function setRequestTimeout(int $seconds): self
- {
- $this->requestTimeout = $seconds;
-
- return $this;
- }
-
- /**
- * Set the number of times a failed connection attempt is retried.
- *
- * Counts retries, not attempts: 3 means one initial attempt plus up to three
- * retries. Pass 0 to fail on the first failure. Only applies to
- * {@see Client::connect()}; individual requests are never retried.
- */
- public function setMaxRetries(int $retries): self
- {
- $this->maxRetries = $retries;
-
- return $this;
- }
-
- /**
- * Set the logger.
- */
- public function setLogger(LoggerInterface $logger): self
- {
- $this->logger = $logger;
-
- return $this;
- }
-
- /**
- * Add a notification handler for server notifications.
- */
- public function addNotificationHandler(NotificationHandlerInterface $handler): self
- {
- $this->notificationHandlers[] = $handler;
-
- return $this;
- }
-
- /**
- * Add a request handler for server requests (e.g., sampling).
- *
- * @param RequestHandlerInterface $handler
- */
- public function addRequestHandler(RequestHandlerInterface $handler): self
- {
- $this->requestHandlers[] = $handler;
-
- return $this;
- }
-
- /**
- * Build the client instance.
- */
- public function build(): Client
- {
- $logger = $this->logger ?? new NullLogger();
-
- $clientInfo = new Implementation(
- $this->name,
- $this->version,
- $this->description,
- );
-
- $config = new Configuration(
- clientInfo: $clientInfo,
- capabilities: $this->capabilities ?? new ClientCapabilities(),
- protocolVersion: $this->protocolVersion ?? ProtocolVersion::V2025_11_25,
- initTimeout: $this->initTimeout,
- requestTimeout: $this->requestTimeout,
- maxRetries: $this->maxRetries,
- );
-
- $protocol = new Protocol(
- requestHandlers: $this->requestHandlers,
- notificationHandlers: $this->notificationHandlers,
- logger: $logger,
- );
-
- return new Client($protocol, $config, $logger);
- }
-}
diff --git a/src/Client/Configuration.php b/src/Client/Configuration.php
deleted file mode 100644
index f0ed6f73..00000000
--- a/src/Client/Configuration.php
+++ /dev/null
@@ -1,46 +0,0 @@
-
- */
-class Configuration
-{
- public function __construct(
- public readonly Implementation $clientInfo,
- public readonly ClientCapabilities $capabilities,
- public readonly ProtocolVersion $protocolVersion = ProtocolVersion::V2025_11_25,
- public readonly int $initTimeout = 30,
- public readonly int $requestTimeout = 120,
- public readonly int $maxRetries = 3,
- ) {
- if ($initTimeout < 1) {
- throw new InvalidArgumentException(\sprintf('The initialization timeout must be a positive number of seconds, got %d.', $initTimeout));
- }
-
- if ($requestTimeout < 1) {
- throw new InvalidArgumentException(\sprintf('The request timeout must be a positive number of seconds, got %d.', $requestTimeout));
- }
-
- if ($maxRetries < 0) {
- throw new InvalidArgumentException(\sprintf('The maximum number of retries must be zero or greater, got %d.', $maxRetries));
- }
- }
-}
diff --git a/src/Client/Handler/Notification/LoggingNotificationHandler.php b/src/Client/Handler/Notification/LoggingNotificationHandler.php
deleted file mode 100644
index c160ccd0..00000000
--- a/src/Client/Handler/Notification/LoggingNotificationHandler.php
+++ /dev/null
@@ -1,43 +0,0 @@
-
- */
-class LoggingNotificationHandler implements NotificationHandlerInterface
-{
- /**
- * @param callable(LoggingMessageNotification): void $callback
- */
- public function __construct(
- private readonly mixed $callback,
- ) {
- }
-
- public function supports(Notification $notification): bool
- {
- return $notification instanceof LoggingMessageNotification;
- }
-
- public function handle(Notification $notification): void
- {
- \assert($notification instanceof LoggingMessageNotification);
-
- ($this->callback)($notification);
- }
-}
diff --git a/src/Client/Handler/Notification/NotificationHandlerInterface.php b/src/Client/Handler/Notification/NotificationHandlerInterface.php
deleted file mode 100644
index 82092aa2..00000000
--- a/src/Client/Handler/Notification/NotificationHandlerInterface.php
+++ /dev/null
@@ -1,32 +0,0 @@
-
- */
-interface NotificationHandlerInterface
-{
- /**
- * Check if this handler supports the given notification.
- */
- public function supports(Notification $notification): bool;
-
- /**
- * Handle the notification.
- */
- public function handle(Notification $notification): void;
-}
diff --git a/src/Client/Handler/Notification/ProgressNotificationHandler.php b/src/Client/Handler/Notification/ProgressNotificationHandler.php
deleted file mode 100644
index 3c489bf0..00000000
--- a/src/Client/Handler/Notification/ProgressNotificationHandler.php
+++ /dev/null
@@ -1,52 +0,0 @@
-
- *
- * @internal
- */
-class ProgressNotificationHandler implements NotificationHandlerInterface
-{
- public function __construct(
- private readonly ClientStateInterface $state,
- ) {
- }
-
- public function supports(Notification $notification): bool
- {
- return $notification instanceof ProgressNotification;
- }
-
- public function handle(Notification $notification): void
- {
- if (!$notification instanceof ProgressNotification) {
- return;
- }
-
- $this->state->storeProgress(
- (string) $notification->progressToken,
- $notification->progress,
- $notification->total,
- $notification->message,
- );
- }
-}
diff --git a/src/Client/Handler/Request/ElicitationCallbackInterface.php b/src/Client/Handler/Request/ElicitationCallbackInterface.php
deleted file mode 100644
index fdaea14c..00000000
--- a/src/Client/Handler/Request/ElicitationCallbackInterface.php
+++ /dev/null
@@ -1,26 +0,0 @@
-
- *
- * @author Johannes Wachter
- */
-class ElicitationRequestHandler implements RequestHandlerInterface
-{
- public function __construct(
- private readonly ElicitationCallbackInterface $callback,
- private readonly LoggerInterface $logger = new NullLogger(),
- ) {
- }
-
- public function supports(Request $request): bool
- {
- return $request instanceof ElicitRequest;
- }
-
- /**
- * @return Response|Error
- */
- public function handle(Request $request): Response|Error
- {
- \assert($request instanceof ElicitRequest);
-
- try {
- $result = $this->callback->__invoke($request);
-
- return new Response($request->getId(), $result);
- } catch (ElicitationException $e) {
- $this->logger->error('Elicitation failed: '.$e->getMessage(), ['exception' => $e]);
-
- return Error::forInternalError($e->getMessage(), $request->getId());
- } catch (\Throwable $e) {
- $this->logger->error('Unexpected error during elicitation', ['exception' => $e]);
-
- return Error::forInternalError('Error while processing elicitation', $request->getId());
- }
- }
-}
diff --git a/src/Client/Handler/Request/ListRootsRequestHandler.php b/src/Client/Handler/Request/ListRootsRequestHandler.php
deleted file mode 100644
index eb18961c..00000000
--- a/src/Client/Handler/Request/ListRootsRequestHandler.php
+++ /dev/null
@@ -1,68 +0,0 @@
-
- *
- * @author Johannes Wachter
- */
-class ListRootsRequestHandler implements RequestHandlerInterface
-{
- public function __construct(
- private readonly RootsCallbackInterface $callback,
- private readonly LoggerInterface $logger = new NullLogger(),
- ) {
- }
-
- public function supports(Request $request): bool
- {
- return $request instanceof ListRootsRequest;
- }
-
- /**
- * @return Response|Error
- */
- public function handle(Request $request): Response|Error
- {
- \assert($request instanceof ListRootsRequest);
-
- try {
- $result = $this->callback->__invoke($request);
-
- return new Response($request->getId(), $result);
- } catch (RootsException $e) {
- $this->logger->error('Listing roots failed: '.$e->getMessage(), ['exception' => $e]);
-
- return Error::forInternalError($e->getMessage(), $request->getId());
- } catch (\Throwable $e) {
- $this->logger->error('Unexpected error while listing roots', ['exception' => $e]);
-
- return Error::forInternalError('Error while listing roots', $request->getId());
- }
- }
-}
diff --git a/src/Client/Handler/Request/RequestHandlerInterface.php b/src/Client/Handler/Request/RequestHandlerInterface.php
deleted file mode 100644
index 1c050181..00000000
--- a/src/Client/Handler/Request/RequestHandlerInterface.php
+++ /dev/null
@@ -1,38 +0,0 @@
-
- */
-interface RequestHandlerInterface
-{
- /**
- * Check if this handler supports the given request.
- */
- public function supports(Request $request): bool;
-
- /**
- * Handle the request and return a response or error.
- *
- * @return Response|Error
- */
- public function handle(Request $request): Response|Error;
-}
diff --git a/src/Client/Handler/Request/RootsCallbackInterface.php b/src/Client/Handler/Request/RootsCallbackInterface.php
deleted file mode 100644
index 80677f14..00000000
--- a/src/Client/Handler/Request/RootsCallbackInterface.php
+++ /dev/null
@@ -1,28 +0,0 @@
-
- */
-interface RootsCallbackInterface
-{
- public function __invoke(ListRootsRequest $request): ListRootsResult;
-}
diff --git a/src/Client/Handler/Request/SamplingCallbackInterface.php b/src/Client/Handler/Request/SamplingCallbackInterface.php
deleted file mode 100644
index bc0d554a..00000000
--- a/src/Client/Handler/Request/SamplingCallbackInterface.php
+++ /dev/null
@@ -1,25 +0,0 @@
-
- *
- * @author Kyrian Obikwelu
- */
-class SamplingRequestHandler implements RequestHandlerInterface
-{
- public function __construct(
- private readonly SamplingCallbackInterface $callback,
- private readonly LoggerInterface $logger = new NullLogger(),
- ) {
- }
-
- public function supports(Request $request): bool
- {
- return $request instanceof CreateSamplingMessageRequest;
- }
-
- /**
- * @return Response|Error
- */
- public function handle(Request $request): Response|Error
- {
- \assert($request instanceof CreateSamplingMessageRequest);
-
- try {
- $request->validateToolFlow();
- } catch (InvalidArgumentException $e) {
- $this->logger->warning('Rejecting sampling request violating the tool flow', ['exception' => $e]);
-
- return Error::forInvalidParams($e->getMessage(), $request->getId());
- }
-
- try {
- $result = $this->callback->__invoke($request);
-
- return new Response($request->getId(), $result);
- } catch (SamplingException $e) {
- $this->logger->error('Sampling failed: '.$e->getMessage(), ['exception' => $e]);
-
- return Error::forInternalError($e->getMessage(), $request->getId());
- } catch (\Throwable $e) {
- $this->logger->error('Unexpected error during sampling', ['exception' => $e]);
-
- return Error::forInternalError('Error while sampling LLM', $request->getId());
- }
- }
-}
diff --git a/src/Client/Protocol.php b/src/Client/Protocol.php
deleted file mode 100644
index e9eabded..00000000
--- a/src/Client/Protocol.php
+++ /dev/null
@@ -1,359 +0,0 @@
-
- */
-class Protocol
-{
- private ?TransportInterface $transport = null;
- private ClientStateInterface $state;
- private MessageFactory $messageFactory;
- private LoggerInterface $logger;
-
- /** @var NotificationHandlerInterface[] */
- private array $notificationHandlers;
-
- /**
- * @param RequestHandlerInterface[] $requestHandlers
- * @param NotificationHandlerInterface[] $notificationHandlers
- */
- public function __construct(
- private readonly array $requestHandlers = [],
- array $notificationHandlers = [],
- ?MessageFactory $messageFactory = null,
- ?LoggerInterface $logger = null,
- ) {
- $this->state = new ClientState();
- $this->messageFactory = $messageFactory ?? MessageFactory::make();
- $this->logger = $logger ?? new NullLogger();
-
- $this->notificationHandlers = [
- new ProgressNotificationHandler($this->state),
- ...$notificationHandlers,
- ];
- }
-
- /**
- * Connect this protocol to a transport.
- *
- * Sets up message handling callbacks.
- *
- * @param TransportInterface $transport The transport to connect
- * @param Configuration $config The client configuration for initialization
- */
- public function connect(TransportInterface $transport, Configuration $config): void
- {
- $this->transport = $transport;
- $transport->setState($this->state);
- $transport->onInitialize(fn () => $this->initialize($config));
- $transport->onMessage($this->processMessage(...));
- $transport->onError(fn (\Throwable $e) => $this->logger->error('Transport error', ['exception' => $e]));
-
- $this->logger->info('Protocol connected to transport', ['transport' => $transport::class]);
- }
-
- /**
- * Perform the MCP initialization handshake.
- *
- * Sends InitializeRequest and waits for response, then sends InitializedNotification.
- *
- * @param Configuration $config The client configuration
- *
- * @return Response>|Error
- */
- public function initialize(Configuration $config): Response|Error
- {
- $offered = $config->protocolVersion;
- if ($offered->isModern()) {
- // Only handshake era spec versions need the initialize call, so if we
- // end up here, we fall back to the latest handshake version.
- $offered = ProtocolVersion::latestHandshake();
-
- $this->logger->warning('Configured protocol version cannot be reached through the "initialize" handshake, offering the newest handshake revision instead.', [
- 'configured' => $config->protocolVersion->value,
- 'offered' => $offered->value,
- ]);
- }
-
- $request = new InitializeRequest(
- $offered->value,
- $config->capabilities,
- $config->clientInfo,
- );
-
- $response = $this->request($request, $config->initTimeout);
-
- if ($response instanceof Response) {
- $initResult = InitializeResult::fromArray($response->result);
-
- // A counter-offer this SDK cannot speak leaves nothing to fall back to,
- // so the handshake fails rather than continuing on a revision neither
- // side agrees on.
- $negotiated = $initResult->protocolVersion;
- if (null === $negotiated || $negotiated->isModern()) {
- // fromArray() above already rejected a missing or non-string revision.
- $counterOffer = (string) $response->result['protocolVersion'];
-
- return Error::forInvalidParams(\sprintf(
- 'Server responded with unsupported protocol version "%s". Supported versions: %s.',
- $counterOffer,
- implode(', ', array_map(
- static fn (ProtocolVersion $v): string => $v->value,
- ProtocolVersion::handshakeVersions(),
- )),
- ), $response->id);
- }
-
- $this->state->setProtocolVersion($negotiated);
- $this->state->setServerInfo($initResult->serverInfo);
- $this->state->setInstructions($initResult->instructions);
- $this->state->setInitialized(true);
-
- $this->sendNotification(new InitializedNotification());
-
- $this->logger->info('Initialization complete', [
- 'server' => $initResult->serverInfo->name,
- 'protocolVersion' => $negotiated->value,
- ]);
- }
-
- return $response;
- }
-
- /**
- * Send a request to the server and wait for response.
- *
- * If a response is immediately available (sync HTTP), returns it.
- * Otherwise, suspends the Fiber and waits for the transport to resume it.
- *
- * @param Request $request The request to send
- * @param int $timeout The timeout in seconds
- * @param bool $withProgress Whether to attach a progress token to the request
- *
- * @return Response>|Error
- */
- public function request(Request $request, int $timeout, bool $withProgress = false): Response|Error
- {
- $requestId = $this->state->nextRequestId();
- $request = $request->withId($requestId);
-
- if ($withProgress) {
- $progressToken = "prog-{$requestId}";
- $request = $request->withMeta(['progressToken' => $progressToken]);
- }
-
- $this->state->addPendingRequest($requestId, $timeout);
-
- try {
- $this->sendRequest($request);
-
- $immediate = $this->state->consumeResponse($requestId);
- if (null !== $immediate) {
- $this->logger->debug('Received immediate response', ['id' => $requestId]);
-
- return $immediate;
- }
-
- $this->logger->debug('Suspending fiber for response', ['id' => $requestId]);
-
- return \Fiber::suspend([
- 'type' => 'await_response',
- 'request_id' => $requestId,
- 'timeout' => $timeout,
- ]);
- } finally {
- // Only the response path clears it, so a request that timed out or
- // whose send() threw would stay pending and fail every later one.
- $this->state->removePendingRequest($requestId);
- }
- }
-
- /**
- * Send a request to the server.
- */
- private function sendRequest(Request $request): void
- {
- $this->logger->debug('Sending request', [
- 'id' => $request->getId(),
- 'method' => $request::getMethod(),
- ]);
-
- $encoded = json_encode($request, \JSON_THROW_ON_ERROR);
- $this->transport?->send($encoded);
- }
-
- /**
- * Send a notification to the server (fire and forget).
- */
- public function sendNotification(Notification $notification): void
- {
- $this->logger->debug('Sending notification', ['method' => $notification::getMethod()]);
-
- $encoded = json_encode($notification, \JSON_THROW_ON_ERROR);
- $this->transport?->send($encoded);
- }
-
- /**
- * Send a response back to the server (for server-initiated requests).
- *
- * @param Response|Error $response
- */
- private function sendResponse(Response|Error $response): void
- {
- $this->logger->debug('Sending response', ['id' => $response->getId()]);
-
- $encoded = json_encode($response, \JSON_THROW_ON_ERROR);
- $this->transport?->send($encoded);
- }
-
- /**
- * Process an incoming message from the server.
- *
- * Routes to appropriate handler based on message type.
- */
- public function processMessage(string $input): void
- {
- $this->logger->debug('Received message', ['input' => $input]);
-
- try {
- $messages = $this->messageFactory->create($input);
- } catch (\JsonException $e) {
- $this->logger->warning('Failed to parse message', ['exception' => $e]);
-
- return;
- }
-
- foreach ($messages as $message) {
- if ($message instanceof Response || $message instanceof Error) {
- $this->handleResponse($message);
- } elseif ($message instanceof Request) {
- $this->handleRequest($message);
- } elseif ($message instanceof Notification) {
- $this->handleNotification($message);
- }
- }
- }
-
- /**
- * Handle a response from the server.
- *
- * This stores it in session. The transport will pick it up and resume the Fiber.
- *
- * @param Response|Error $response
- */
- private function handleResponse(Response|Error $response): void
- {
- $requestId = $response->getId();
-
- $this->logger->debug('Handling response', ['id' => $requestId]);
-
- $this->state->storeResponse($requestId, $response->jsonSerialize());
- }
-
- /**
- * Handle a request from the server (e.g., sampling request).
- */
- private function handleRequest(Request $request): void
- {
- $method = $request::getMethod();
-
- $this->logger->debug('Received server request', [
- 'method' => $method,
- 'id' => $request->getId(),
- ]);
-
- foreach ($this->requestHandlers as $handler) {
- if ($handler->supports($request)) {
- try {
- $response = $handler->handle($request);
- } catch (\Throwable $e) {
- $this->logger->error('Unexpected error while handling request', [
- 'method' => $method,
- 'exception' => $e,
- ]);
-
- $response = Error::forInternalError(
- \sprintf('Unexpected error while handling "%s" request', $method),
- $request->getId()
- );
- }
-
- $this->sendResponse($response);
-
- return;
- }
- }
-
- $error = Error::forMethodNotFound(
- \sprintf('Client does not handle "%s" requests.', $method),
- $request->getId()
- );
-
- $this->sendResponse($error);
- }
-
- /**
- * Handle a notification from the server.
- */
- private function handleNotification(Notification $notification): void
- {
- $method = $notification::getMethod();
-
- $this->logger->debug('Received server notification', [
- 'method' => $method,
- ]);
-
- foreach ($this->notificationHandlers as $handler) {
- if ($handler->supports($notification)) {
- try {
- $handler->handle($notification);
- } catch (\Throwable $e) {
- $this->logger->warning('Notification handler failed', ['exception' => $e]);
- }
-
- return;
- }
- }
- }
-
- public function getState(): ClientStateInterface
- {
- return $this->state;
- }
-}
diff --git a/src/Client/State/ClientState.php b/src/Client/State/ClientState.php
deleted file mode 100644
index a1e1f896..00000000
--- a/src/Client/State/ClientState.php
+++ /dev/null
@@ -1,148 +0,0 @@
-
- */
-class ClientState implements ClientStateInterface
-{
- private int $requestIdCounter = 1;
- private bool $initialized = false;
- private ?ProtocolVersion $protocolVersion = null;
- private ?Implementation $serverInfo = null;
- private ?string $instructions = null;
-
- /** @var array */
- private array $pendingRequests = [];
-
- /** @var array> */
- private array $responses = [];
-
- /** @var array */
- private array $progressUpdates = [];
-
- public function nextRequestId(): int
- {
- return $this->requestIdCounter++;
- }
-
- public function addPendingRequest(int|string $requestId, int $timeout): void
- {
- $this->pendingRequests[$requestId] = [
- 'request_id' => $requestId,
- 'timestamp' => time(),
- 'timeout' => $timeout,
- ];
- }
-
- public function removePendingRequest(int|string $requestId): void
- {
- unset($this->pendingRequests[$requestId]);
- }
-
- public function getPendingRequests(): array
- {
- return $this->pendingRequests;
- }
-
- public function storeResponse(int|string $requestId, array $responseData): void
- {
- $this->responses[$requestId] = $responseData;
- }
-
- public function consumeResponse(int|string $requestId): Response|Error|null
- {
- if (!isset($this->responses[$requestId])) {
- return null;
- }
-
- $data = $this->responses[$requestId];
- unset($this->responses[$requestId]);
- $this->removePendingRequest($requestId);
-
- if (isset($data['error'])) {
- return Error::fromArray($data);
- }
-
- return Response::fromArray($data);
- }
-
- public function setInitialized(bool $initialized): void
- {
- $this->initialized = $initialized;
- }
-
- public function isInitialized(): bool
- {
- return $this->initialized;
- }
-
- public function setProtocolVersion(ProtocolVersion $protocolVersion): void
- {
- $this->protocolVersion = $protocolVersion;
- }
-
- public function getProtocolVersion(): ?ProtocolVersion
- {
- return $this->protocolVersion;
- }
-
- public function setServerInfo(Implementation $serverInfo): void
- {
- $this->serverInfo = $serverInfo;
- }
-
- public function getServerInfo(): ?Implementation
- {
- return $this->serverInfo;
- }
-
- public function setInstructions(?string $instructions): void
- {
- $this->instructions = $instructions;
- }
-
- public function getInstructions(): ?string
- {
- return $this->instructions;
- }
-
- public function storeProgress(string $token, float $progress, ?float $total, ?string $message): void
- {
- $this->progressUpdates[] = [
- 'token' => $token,
- 'progress' => $progress,
- 'total' => $total,
- 'message' => $message,
- ];
- }
-
- public function consumeProgressUpdates(): array
- {
- $updates = $this->progressUpdates;
- $this->progressUpdates = [];
-
- return $updates;
- }
-}
diff --git a/src/Client/State/ClientStateInterface.php b/src/Client/State/ClientStateInterface.php
deleted file mode 100644
index a5de42d9..00000000
--- a/src/Client/State/ClientStateInterface.php
+++ /dev/null
@@ -1,128 +0,0 @@
-
- */
-interface ClientStateInterface
-{
- /**
- * Get the next request ID for outgoing requests.
- */
- public function nextRequestId(): int;
-
- /**
- * Add a pending request to track.
- *
- * @param int|string $requestId The request ID
- * @param int $timeout Timeout in seconds
- */
- public function addPendingRequest(int|string $requestId, int $timeout): void;
-
- /**
- * Remove a pending request.
- */
- public function removePendingRequest(int|string $requestId): void;
-
- /**
- * Get all pending requests.
- *
- * @return array
- */
- public function getPendingRequests(): array;
-
- /**
- * Store a received response.
- *
- * @param int|string $requestId The request ID
- * @param array $responseData The raw response data
- */
- public function storeResponse(int|string $requestId, array $responseData): void;
-
- /**
- * Check and consume a response for a request ID.
- *
- * @return Response>|Error|null
- */
- public function consumeResponse(int|string $requestId): Response|Error|null;
-
- /**
- * Set initialization state.
- */
- public function setInitialized(bool $initialized): void;
-
- /**
- * Check if connection is initialized.
- */
- public function isInitialized(): bool;
-
- /**
- * Store the protocol version negotiated during initialization.
- */
- public function setProtocolVersion(ProtocolVersion $protocolVersion): void;
-
- /**
- * Get the protocol version negotiated during initialization.
- *
- * Null until the handshake has completed.
- */
- public function getProtocolVersion(): ?ProtocolVersion;
-
- /**
- * Store the server info from initialization.
- */
- public function setServerInfo(Implementation $serverInfo): void;
-
- /**
- * Get the server info from initialization.
- */
- public function getServerInfo(): ?Implementation;
-
- /**
- * Store the server instructions from initialization.
- */
- public function setInstructions(?string $instructions): void;
-
- /**
- * Get the server instructions from initialization.
- */
- public function getInstructions(): ?string;
-
- /**
- * Store progress data received from a notification.
- *
- * @param string $token The progress token
- * @param float $progress Current progress value
- * @param float|null $total Total progress value (if known)
- * @param string|null $message Progress message
- */
- public function storeProgress(string $token, float $progress, ?float $total, ?string $message): void;
-
- /**
- * Consume all pending progress updates.
- *
- * @return array
- */
- public function consumeProgressUpdates(): array;
-}
diff --git a/src/Client/Transport/BaseTransport.php b/src/Client/Transport/BaseTransport.php
deleted file mode 100644
index a1fc1936..00000000
--- a/src/Client/Transport/BaseTransport.php
+++ /dev/null
@@ -1,126 +0,0 @@
-
- */
-abstract class BaseTransport implements TransportInterface
-{
- /** @var callable(): mixed|null */
- protected $initializeCallback;
-
- /** @var callable(string): void|null */
- protected $messageCallback;
-
- /** @var callable(\Throwable): void|null */
- protected $errorCallback;
-
- /** @var callable(string): void|null */
- protected $closeCallback;
-
- protected ?ClientStateInterface $state = null;
- protected LoggerInterface $logger;
-
- public function __construct(?LoggerInterface $logger = null)
- {
- $this->logger = $logger ?? new NullLogger();
- }
-
- public function onInitialize(callable $listener): void
- {
- $this->initializeCallback = $listener;
- }
-
- public function onMessage(callable $listener): void
- {
- $this->messageCallback = $listener;
- }
-
- public function onError(callable $listener): void
- {
- $this->errorCallback = $listener;
- }
-
- public function onClose(callable $listener): void
- {
- $this->closeCallback = $listener;
- }
-
- public function setState(ClientStateInterface $state): void
- {
- $this->state = $state;
- }
-
- /**
- * Perform initialization via the registered callback.
- *
- * @return mixed The result from the initialization callback
- *
- * @throws RuntimeException If no initialize listener is registered
- */
- protected function handleInitialize(): mixed
- {
- if (!\is_callable($this->initializeCallback)) {
- throw new RuntimeException('No initialize listener registered');
- }
-
- return ($this->initializeCallback)();
- }
-
- /**
- * Handle an incoming message from the server.
- */
- protected function handleMessage(string $message): void
- {
- if (\is_callable($this->messageCallback)) {
- try {
- ($this->messageCallback)($message);
- } catch (\Throwable $e) {
- $this->handleError($e);
- }
- }
- }
-
- /**
- * Handle a transport error.
- */
- protected function handleError(\Throwable $error): void
- {
- $this->logger->error('Transport error', ['exception' => $error]);
-
- if (\is_callable($this->errorCallback)) {
- ($this->errorCallback)($error);
- }
- }
-
- /**
- * Handle connection close.
- */
- protected function handleClose(string $reason): void
- {
- $this->logger->info('Transport closed', ['reason' => $reason]);
-
- if (\is_callable($this->closeCallback)) {
- ($this->closeCallback)($reason);
- }
- }
-}
diff --git a/src/Client/Transport/HttpTransport.php b/src/Client/Transport/HttpTransport.php
deleted file mode 100644
index ddb662f7..00000000
--- a/src/Client/Transport/HttpTransport.php
+++ /dev/null
@@ -1,387 +0,0 @@
-
- */
-class HttpTransport extends BaseTransport
-{
- private ClientInterface $httpClient;
- private RequestFactoryInterface $requestFactory;
- private StreamFactoryInterface $streamFactory;
-
- private ?string $sessionId = null;
-
- /** @var McpFiber|null */
- private ?\Fiber $activeFiber = null;
-
- /** @var (callable(float, ?float, ?string): void)|null */
- private $activeProgressCallback;
-
- /** @var StreamInterface|null Active SSE stream being read */
- private ?StreamInterface $activeStream = null;
-
- /** @var string Buffer for incomplete SSE data */
- private string $sseBuffer = '';
-
- /**
- * Default cap on the bytes buffered while waiting for a complete SSE event.
- */
- public const DEFAULT_MAX_SSE_BUFFER_BYTES = 8 * 1024 * 1024;
-
- private readonly int $maxSseBufferBytes;
-
- /**
- * @param string $endpoint The MCP server endpoint URL
- * @param array $headers Additional headers to send
- * @param ClientInterface|null $httpClient PSR-18 HTTP client (auto-discovered if null)
- * @param RequestFactoryInterface|null $requestFactory PSR-17 request factory (auto-discovered if null)
- * @param StreamFactoryInterface|null $streamFactory PSR-17 stream factory (auto-discovered if null)
- * @param int $maxSseBufferBytes Maximum bytes buffered while waiting for a complete
- * SSE event. A server that never sends the "\n\n" event
- * delimiter would otherwise grow the buffer without bound
- * and exhaust client memory; reaching the cap aborts the
- * stream instead. Raise it for servers that legitimately
- * emit single events larger than the default.
- */
- public function __construct(
- private readonly string $endpoint,
- private readonly array $headers = [],
- ?ClientInterface $httpClient = null,
- ?RequestFactoryInterface $requestFactory = null,
- ?StreamFactoryInterface $streamFactory = null,
- ?LoggerInterface $logger = null,
- int $maxSseBufferBytes = self::DEFAULT_MAX_SSE_BUFFER_BYTES,
- ) {
- parent::__construct($logger);
-
- if ($maxSseBufferBytes < 1) {
- throw new InvalidArgumentException(\sprintf('The maximum SSE buffer size must be a positive number of bytes, got %d.', $maxSseBufferBytes));
- }
-
- $this->maxSseBufferBytes = $maxSseBufferBytes;
- $this->httpClient = $httpClient ?? Psr18ClientDiscovery::find();
- $this->requestFactory = $requestFactory ?? Psr17FactoryDiscovery::findRequestFactory();
- $this->streamFactory = $streamFactory ?? Psr17FactoryDiscovery::findStreamFactory();
- }
-
- public function connect(): void
- {
- $this->activeFiber = new \Fiber(fn () => $this->handleInitialize());
-
- $this->activeFiber->start();
-
- while (!$this->activeFiber->isTerminated()) {
- $this->tick();
- }
-
- $result = $this->activeFiber->getReturn();
- $this->activeFiber = null;
-
- if ($result instanceof Error) {
- throw new ConnectionException('Initialization failed: '.$result->message);
- }
-
- $this->logger->info('HTTP client connected and initialized', ['endpoint' => $this->endpoint]);
- }
-
- public function send(string $data): void
- {
- $request = $this->requestFactory->createRequest('POST', $this->endpoint)
- ->withHeader('Content-Type', 'application/json')
- ->withHeader('Accept', 'application/json, text/event-stream')
- ->withBody($this->streamFactory->createStream($data));
-
- if (null !== $this->sessionId) {
- $request = $request->withHeader('Mcp-Session-Id', $this->sessionId);
- }
-
- foreach ($this->headers as $name => $value) {
- $request = $request->withHeader($name, $value);
- }
-
- $this->logger->debug('Sending HTTP request', ['data' => $data]);
-
- try {
- $response = $this->httpClient->sendRequest($request);
- } catch (\Throwable $e) {
- $this->handleError($e);
- throw new ConnectionException('HTTP request failed: '.$e->getMessage(), 0, $e);
- }
-
- if ($response->hasHeader('Mcp-Session-Id')) {
- $this->sessionId = $response->getHeaderLine('Mcp-Session-Id');
- $this->logger->debug('Received session ID', ['session_id' => $this->sessionId]);
- }
-
- $contentType = strtolower($response->getHeaderLine('Content-Type'));
-
- if (str_contains($contentType, 'text/event-stream')) {
- $this->activeStream = $response->getBody();
- $this->sseBuffer = '';
- } elseif (str_contains($contentType, 'application/json')) {
- $body = $response->getBody()->getContents();
- if (!empty($body)) {
- $this->handleMessage($body);
- }
- }
- }
-
- /**
- * @param McpFiber $fiber
- * @param (callable(float $progress, ?float $total, ?string $message): void)|null $onProgress
- */
- public function runRequest(\Fiber $fiber, ?callable $onProgress = null): Response|Error
- {
- $this->activeFiber = $fiber;
- $this->activeProgressCallback = $onProgress;
- $fiber->start();
-
- while (!$fiber->isTerminated()) {
- $this->tick();
- }
-
- $this->activeFiber = null;
- $this->activeProgressCallback = null;
- $this->activeStream = null;
-
- return $fiber->getReturn();
- }
-
- public function close(): void
- {
- if (null !== $this->sessionId) {
- try {
- $request = $this->requestFactory->createRequest('DELETE', $this->endpoint)
- ->withHeader('Mcp-Session-Id', $this->sessionId);
-
- foreach ($this->headers as $name => $value) {
- $request = $request->withHeader($name, $value);
- }
-
- $this->httpClient->sendRequest($request);
- $this->logger->info('Session closed', ['session_id' => $this->sessionId]);
- } catch (\Throwable $e) {
- $this->logger->warning('Failed to close session', ['exception' => $e]);
- }
- }
-
- $this->sessionId = null;
- $this->activeStream = null;
- $this->handleClose('Transport closed');
- }
-
- private function tick(): void
- {
- $this->processSSEStream();
- $this->processProgress();
- $this->processFiber();
-
- usleep(1000); // 1ms
- }
-
- /**
- * Read SSE data incrementally from active stream.
- */
- private function processSSEStream(): void
- {
- if (null === $this->activeStream) {
- return;
- }
-
- if (!$this->activeStream->eof()) {
- $chunk = $this->activeStream->read(4096);
- if ('' !== $chunk) {
- if (\strlen($this->sseBuffer) + \strlen($chunk) > $this->maxSseBufferBytes) {
- $this->abortSseStream(\sprintf('buffered %d bytes without a complete event, exceeding the %d byte limit', \strlen($this->sseBuffer) + \strlen($chunk), $this->maxSseBufferBytes));
-
- return;
- }
-
- $this->sseBuffer .= $chunk;
- }
- }
-
- while (null !== ($event = $this->extractSSEEvent())) {
- if (!empty(trim($event))) {
- $this->processSSEEvent($event);
- }
- }
-
- if ($this->activeStream->eof()) {
- // The stream ended without a trailing blank line: dispatch what is left.
- if (!empty(trim($this->sseBuffer))) {
- $this->processSSEEvent($this->sseBuffer);
- }
-
- $this->sseBuffer = '';
- $this->activeStream = null;
- }
- }
-
- /**
- * Tear down the active SSE stream and fail any in-flight request.
- *
- * The waiting fiber is resolved with an error immediately so the caller
- * fails fast, rather than spinning until the request timeout elapses.
- */
- private function abortSseStream(string $reason): void
- {
- $bufferedBytes = \strlen($this->sseBuffer);
- $this->sseBuffer = '';
- $this->activeStream = null;
-
- $this->logger->warning('Aborting SSE stream: '.$reason, [
- 'session_id' => $this->sessionId,
- 'buffered_bytes' => $bufferedBytes,
- 'max_sse_buffer_bytes' => $this->maxSseBufferBytes,
- ]);
-
- if (null === $this->state) {
- return;
- }
-
- foreach ($this->state->getPendingRequests() as $pending) {
- $requestId = $pending['request_id'];
- $error = Error::forInternalError('SSE stream aborted: '.$reason, $requestId);
- $this->state->storeResponse($requestId, $error->jsonSerialize());
- }
- }
-
- /**
- * Take the next complete event off the buffer, or null if none is complete yet.
- *
- * Per the SSE specification, lines are terminated by CRLF, LF or CR, so an
- * event is delimited by any pair of those. Servers built on sse-starlette
- * (the MCP Python SDK) use CRLF.
- */
- private function extractSSEEvent(): ?string
- {
- $position = null;
- $length = 0;
-
- foreach (["\r\n\r\n", "\n\n", "\r\r"] as $delimiter) {
- $found = strpos($this->sseBuffer, $delimiter);
-
- if (false !== $found && (null === $position || $found < $position)) {
- $position = $found;
- $length = \strlen($delimiter);
- }
- }
-
- if (null === $position) {
- return null;
- }
-
- $event = substr($this->sseBuffer, 0, $position);
- $this->sseBuffer = substr($this->sseBuffer, $position + $length);
-
- return $event;
- }
-
- /**
- * Parse a single SSE event and handle the message.
- */
- private function processSSEEvent(string $event): void
- {
- $data = '';
-
- foreach (preg_split("/\r\n|\r|\n/", $event) ?: [] as $line) {
- if (str_starts_with($line, 'data:')) {
- $data .= trim(substr($line, 5));
- }
- }
-
- if (!empty($data)) {
- $this->handleMessage($data);
- }
- }
-
- /**
- * Process pending progress updates from session and execute callback.
- */
- private function processProgress(): void
- {
- if (null === $this->activeProgressCallback || null === $this->state) {
- return;
- }
-
- $updates = $this->state->consumeProgressUpdates();
-
- foreach ($updates as $update) {
- try {
- ($this->activeProgressCallback)(
- $update['progress'],
- $update['total'],
- $update['message'],
- );
- } catch (\Throwable $e) {
- $this->logger->warning('Progress callback failed', ['exception' => $e]);
- }
- }
- }
-
- private function processFiber(): void
- {
- if (null === $this->activeFiber || !$this->activeFiber->isSuspended()) {
- return;
- }
-
- if (null === $this->state) {
- return;
- }
-
- $pendingRequests = $this->state->getPendingRequests();
-
- foreach ($pendingRequests as $pending) {
- $requestId = $pending['request_id'];
- $timestamp = $pending['timestamp'];
- $timeout = $pending['timeout'];
-
- $response = $this->state->consumeResponse($requestId);
-
- if (null !== $response) {
- $this->logger->debug('Resuming fiber with response', ['request_id' => $requestId]);
- $this->activeFiber->resume($response);
-
- return;
- }
-
- if (time() - $timestamp >= $timeout) {
- $this->logger->warning('Request timed out', ['request_id' => $requestId]);
- $error = Error::forInternalError('Request timed out', $requestId);
- $this->activeFiber->resume($error);
-
- return;
- }
- }
- }
-}
diff --git a/src/Client/Transport/StdioTransport.php b/src/Client/Transport/StdioTransport.php
deleted file mode 100644
index f1029619..00000000
--- a/src/Client/Transport/StdioTransport.php
+++ /dev/null
@@ -1,339 +0,0 @@
-
- */
-class StdioTransport extends BaseTransport
-{
- /** @var resource|null */
- private $process;
-
- /** @var resource|null */
- private $stdin;
-
- /** @var resource|null */
- private $stdout;
-
- /** @var resource|null */
- private $stderr;
-
- private string $inputBuffer = '';
-
- /**
- * Default cap on the bytes buffered while waiting for a complete line.
- */
- public const DEFAULT_MAX_BUFFER_SIZE = 4 * 1024 * 1024;
-
- /** @var McpFiber|null */
- private ?\Fiber $activeFiber = null;
-
- /** @var (callable(float, ?float, ?string): void)|null */
- private $activeProgressCallback;
-
- /**
- * @param string $command The command to run
- * @param array $args Command arguments
- * @param string|null $cwd Working directory
- * @param array|null $env Environment variables
- * @param int $maxBufferSize Maximum bytes buffered while waiting for a complete line. The
- * buffer is only drained on a "\n"; a spawned server that streams
- * stdout without ever emitting a newline would otherwise grow it
- * without bound and exhaust client memory. Reaching the cap aborts
- * the read instead. Raise it for servers that emit single frames
- * larger than the default.
- */
- public function __construct(
- private readonly string $command,
- private readonly array $args = [],
- private readonly ?string $cwd = null,
- private readonly ?array $env = null,
- ?LoggerInterface $logger = null,
- private readonly int $maxBufferSize = self::DEFAULT_MAX_BUFFER_SIZE,
- ) {
- parent::__construct($logger);
-
- if ($maxBufferSize < 1) {
- throw new InvalidArgumentException(\sprintf('The maximum buffer size must be a positive number of bytes, got %d.', $maxBufferSize));
- }
- }
-
- public function connect(): void
- {
- $this->spawnProcess();
-
- $this->activeFiber = new \Fiber(fn () => $this->handleInitialize());
-
- $this->activeFiber->start();
-
- while (!$this->activeFiber->isTerminated()) {
- $this->tick();
- }
-
- $result = $this->activeFiber->getReturn();
- $this->activeFiber = null;
-
- if ($result instanceof Error) {
- $this->close();
- throw new ConnectionException('Initialization failed: '.$result->message);
- }
-
- $this->logger->info('Client connected and initialized');
- }
-
- public function send(string $data): void
- {
- if (null === $this->stdin || !\is_resource($this->stdin)) {
- throw new ConnectionException('Process stdin not available');
- }
-
- fwrite($this->stdin, $data."\n");
- fflush($this->stdin);
-
- $this->logger->debug('Sent message to server', ['data' => $data]);
- }
-
- /**
- * @param McpFiber $fiber
- * @param (callable(float $progress, ?float $total, ?string $message): void)|null $onProgress
- */
- public function runRequest(\Fiber $fiber, ?callable $onProgress = null): Response|Error
- {
- $this->activeFiber = $fiber;
- $this->activeProgressCallback = $onProgress;
- $fiber->start();
-
- while (!$fiber->isTerminated()) {
- $this->tick();
- }
-
- $this->activeFiber = null;
- $this->activeProgressCallback = null;
-
- return $fiber->getReturn();
- }
-
- public function close(): void
- {
- if (\is_resource($this->stdin)) {
- fclose($this->stdin);
- $this->stdin = null;
- }
- if (\is_resource($this->stdout)) {
- fclose($this->stdout);
- $this->stdout = null;
- }
- if (\is_resource($this->stderr)) {
- fclose($this->stderr);
- $this->stderr = null;
- }
- if (\is_resource($this->process)) {
- proc_terminate($this->process, 15); // SIGTERM
- proc_close($this->process);
- $this->process = null;
- }
-
- $this->handleClose('Transport closed');
- }
-
- private function spawnProcess(): void
- {
- $descriptors = [
- 0 => ['pipe', 'r'], // stdin
- 1 => ['pipe', 'w'], // stdout
- 2 => ['pipe', 'w'], // stderr
- ];
-
- $cmd = escapeshellcmd($this->command);
- foreach ($this->args as $arg) {
- $cmd .= ' '.escapeshellarg($arg);
- }
-
- $this->process = proc_open(
- $cmd,
- $descriptors,
- $pipes,
- $this->cwd,
- $this->env
- );
-
- if (!\is_resource($this->process)) {
- throw new ConnectionException('Failed to start process: '.$cmd);
- }
-
- $this->stdin = $pipes[0];
- $this->stdout = $pipes[1];
- $this->stderr = $pipes[2];
-
- // Set non-blocking mode for reading
- stream_set_blocking($this->stdout, false);
- stream_set_blocking($this->stderr, false);
-
- $this->logger->info('Started MCP server process', ['command' => $cmd]);
- }
-
- private function tick(): void
- {
- $this->processInput();
- $this->processProgress();
- $this->processFiber();
- $this->processStderr();
-
- usleep(1000); // 1ms
- }
-
- /**
- * Process pending progress updates from session and execute callback.
- */
- private function processProgress(): void
- {
- if (null === $this->activeProgressCallback || null === $this->state) {
- return;
- }
-
- $updates = $this->state->consumeProgressUpdates();
-
- foreach ($updates as $update) {
- try {
- ($this->activeProgressCallback)(
- $update['progress'],
- $update['total'],
- $update['message'],
- );
- } catch (\Throwable $e) {
- $this->logger->warning('Progress callback failed', ['exception' => $e]);
- }
- }
- }
-
- private function processInput(): void
- {
- if (null === $this->stdout || !\is_resource($this->stdout)) {
- return;
- }
-
- $data = fread($this->stdout, 8192);
- if (false !== $data && '' !== $data) {
- if (\strlen($this->inputBuffer) + \strlen($data) > $this->maxBufferSize) {
- $this->abortInput(\sprintf('buffered %d bytes without a newline, exceeding the %d byte limit', \strlen($this->inputBuffer) + \strlen($data), $this->maxBufferSize));
-
- return;
- }
-
- $this->inputBuffer .= $data;
- }
-
- while (false !== ($pos = strpos($this->inputBuffer, "\n"))) {
- $line = substr($this->inputBuffer, 0, $pos);
- $this->inputBuffer = substr($this->inputBuffer, $pos + 1);
-
- $trimmed = trim($line);
- if (!empty($trimmed)) {
- $this->handleMessage($trimmed);
- }
- }
- }
-
- /**
- * Discard the input buffer and fail any in-flight request.
- *
- * The waiting fiber is resolved with an error immediately so the caller
- * fails fast, rather than spinning until the request timeout elapses.
- */
- private function abortInput(string $reason): void
- {
- $bufferedBytes = \strlen($this->inputBuffer);
- $this->inputBuffer = '';
-
- $this->logger->warning('Aborting stdio input: '.$reason, [
- 'buffered_bytes' => $bufferedBytes,
- 'max_buffer_size' => $this->maxBufferSize,
- ]);
-
- if (null === $this->state) {
- return;
- }
-
- foreach ($this->state->getPendingRequests() as $pending) {
- $requestId = $pending['request_id'];
- $error = Error::forInternalError('stdio input aborted: '.$reason, $requestId);
- $this->state->storeResponse($requestId, $error->jsonSerialize());
- }
- }
-
- private function processFiber(): void
- {
- if (null === $this->activeFiber || !$this->activeFiber->isSuspended()) {
- return;
- }
-
- if (null === $this->state) {
- return;
- }
-
- $pendingRequests = $this->state->getPendingRequests();
-
- foreach ($pendingRequests as $pending) {
- $requestId = $pending['request_id'];
- $timestamp = $pending['timestamp'];
- $timeout = $pending['timeout'];
-
- // Check if response arrived
- $response = $this->state->consumeResponse($requestId);
-
- if (null !== $response) {
- $this->logger->debug('Resuming fiber with response', ['request_id' => $requestId]);
- $this->activeFiber->resume($response);
-
- return;
- }
-
- // Check timeout
- if (time() - $timestamp >= $timeout) {
- $this->logger->warning('Request timed out', ['request_id' => $requestId]);
- $error = Error::forInternalError('Request timed out', $requestId);
- $this->activeFiber->resume($error);
-
- return;
- }
- }
- }
-
- private function processStderr(): void
- {
- if (null === $this->stderr || !\is_resource($this->stderr)) {
- return;
- }
-
- $stderr = fread($this->stderr, 8192);
- if (false !== $stderr && '' !== $stderr) {
- $this->logger->debug('Server stderr', ['output' => trim($stderr)]);
- }
- }
-}
diff --git a/src/Client/Transport/TransportInterface.php b/src/Client/Transport/TransportInterface.php
deleted file mode 100644
index 4860128e..00000000
--- a/src/Client/Transport/TransportInterface.php
+++ /dev/null
@@ -1,107 +0,0 @@
-|Error)
- * @phpstan-type FiberResume (Response|Error)
- * @phpstan-type FiberSuspend array{type: 'await_response', request_id: int, timeout: int}
- * @phpstan-type McpFiber \Fiber
- *
- * @author Kyrian Obikwelu
- */
-interface TransportInterface
-{
- /**
- * Connect to the MCP server and perform initialization handshake.
- *
- * This method blocks until:
- * - Initialization completes successfully
- * - Connection fails (throws ConnectionException)
- *
- * @throws \Mcp\Exception\ConnectionException
- */
- public function connect(): void;
-
- /**
- * Send a message to the server immediately.
- *
- * @param string $data JSON-encoded message
- */
- public function send(string $data): void;
-
- /**
- * Run a request fiber to completion.
- *
- * The transport starts the fiber, runs its internal loop, and resumes
- * the fiber when a response arrives or timeout occurs.
- *
- * During the loop, the transport checks session for progress data and
- * executes the callback if provided.
- *
- * @param McpFiber $fiber The fiber to execute
- * @param (callable(float $progress, ?float $total, ?string $message): void)|null $onProgress
- * Optional callback for progress updates
- *
- * @return Response>|Error The response or error
- */
- public function runRequest(\Fiber $fiber, ?callable $onProgress = null): Response|Error;
-
- /**
- * Close the transport and clean up resources.
- */
- public function close(): void;
-
- /**
- * Register callback for initialization handshake.
- *
- * The callback should return a Fiber that performs the initialization.
- *
- * @param callable(): mixed $callback
- */
- public function onInitialize(callable $callback): void;
-
- /**
- * Register callback for incoming messages from server.
- *
- * @param callable(string $message): void $callback
- */
- public function onMessage(callable $callback): void;
-
- /**
- * Register callback for transport errors.
- *
- * @param callable(\Throwable $error): void $callback
- */
- public function onError(callable $callback): void;
-
- /**
- * Register callback for when connection closes.
- *
- * @param callable(string $reason): void $callback
- */
- public function onClose(callable $callback): void;
-
- /**
- * Set the client state for runtime state management.
- */
- public function setState(ClientStateInterface $state): void;
-}
diff --git a/src/Event/ErrorEvent.php b/src/Event/ErrorEvent.php
deleted file mode 100644
index 65ce650d..00000000
--- a/src/Event/ErrorEvent.php
+++ /dev/null
@@ -1,59 +0,0 @@
-
- */
-final class ErrorEvent
-{
- public function __construct(
- private Error $error,
- private readonly Request $request,
- private readonly SessionInterface $session,
- private readonly ?\Throwable $throwable,
- ) {
- }
-
- public function getError(): Error
- {
- return $this->error;
- }
-
- public function setError(Error $error): void
- {
- $this->error = $error;
- }
-
- public function getRequest(): Request
- {
- return $this->request;
- }
-
- public function getThrowable(): ?\Throwable
- {
- return $this->throwable;
- }
-
- public function getSession(): SessionInterface
- {
- return $this->session;
- }
-}
diff --git a/src/Event/NotificationEvent.php b/src/Event/NotificationEvent.php
deleted file mode 100644
index 579d877d..00000000
--- a/src/Event/NotificationEvent.php
+++ /dev/null
@@ -1,51 +0,0 @@
-
- */
-final class NotificationEvent
-{
- public function __construct(
- private Notification $notification,
- private readonly SessionInterface $session,
- ) {
- }
-
- public function getNotification(): Notification
- {
- return $this->notification;
- }
-
- public function setNotification(Notification $notification): void
- {
- $this->notification = $notification;
- }
-
- public function getSession(): SessionInterface
- {
- return $this->session;
- }
-
- public function getMethod(): string
- {
- return $this->notification::getMethod();
- }
-}
diff --git a/src/Event/PromptListChangedEvent.php b/src/Event/PromptListChangedEvent.php
deleted file mode 100644
index 2e869181..00000000
--- a/src/Event/PromptListChangedEvent.php
+++ /dev/null
@@ -1,19 +0,0 @@
-
- */
-final class PromptListChangedEvent
-{
-}
diff --git a/src/Event/RequestEvent.php b/src/Event/RequestEvent.php
deleted file mode 100644
index 3a7de08e..00000000
--- a/src/Event/RequestEvent.php
+++ /dev/null
@@ -1,51 +0,0 @@
-
- */
-final class RequestEvent
-{
- public function __construct(
- private Request $request,
- private readonly SessionInterface $session,
- ) {
- }
-
- public function getRequest(): Request
- {
- return $this->request;
- }
-
- public function setRequest(Request $request): void
- {
- $this->request = $request;
- }
-
- public function getSession(): SessionInterface
- {
- return $this->session;
- }
-
- public function getMethod(): string
- {
- return $this->request::getMethod();
- }
-}
diff --git a/src/Event/ResourceListChangedEvent.php b/src/Event/ResourceListChangedEvent.php
deleted file mode 100644
index 83120d68..00000000
--- a/src/Event/ResourceListChangedEvent.php
+++ /dev/null
@@ -1,19 +0,0 @@
-
- */
-final class ResourceListChangedEvent
-{
-}
diff --git a/src/Event/ResourceTemplateListChangedEvent.php b/src/Event/ResourceTemplateListChangedEvent.php
deleted file mode 100644
index 0c13f654..00000000
--- a/src/Event/ResourceTemplateListChangedEvent.php
+++ /dev/null
@@ -1,19 +0,0 @@
-
- */
-final class ResourceTemplateListChangedEvent
-{
-}
diff --git a/src/Event/ResponseEvent.php b/src/Event/ResponseEvent.php
deleted file mode 100644
index 94b9a907..00000000
--- a/src/Event/ResponseEvent.php
+++ /dev/null
@@ -1,67 +0,0 @@
-
- */
-final class ResponseEvent
-{
- /**
- * @param Response $response
- */
- public function __construct(
- private Response $response,
- private readonly Request $request,
- private readonly SessionInterface $session,
- ) {
- }
-
- /**
- * @return Response
- */
- public function getResponse(): Response
- {
- return $this->response;
- }
-
- /**
- * @param Response $response
- */
- public function setResponse(Response $response): void
- {
- $this->response = $response;
- }
-
- public function getRequest(): Request
- {
- return $this->request;
- }
-
- public function getSession(): SessionInterface
- {
- return $this->session;
- }
-
- public function getMethod(): string
- {
- return $this->request::getMethod();
- }
-}
diff --git a/src/Event/ToolListChangedEvent.php b/src/Event/ToolListChangedEvent.php
deleted file mode 100644
index 84d175a2..00000000
--- a/src/Event/ToolListChangedEvent.php
+++ /dev/null
@@ -1,19 +0,0 @@
-
- */
-final class ToolListChangedEvent
-{
-}
diff --git a/src/Exception/BadMethodCallException.php b/src/Exception/BadMethodCallException.php
deleted file mode 100644
index d7c00c14..00000000
--- a/src/Exception/BadMethodCallException.php
+++ /dev/null
@@ -1,19 +0,0 @@
-
- */
-final class BadMethodCallException extends \BadMethodCallException implements ExceptionInterface
-{
-}
diff --git a/src/Exception/ClientException.php b/src/Exception/ClientException.php
deleted file mode 100644
index 33e36680..00000000
--- a/src/Exception/ClientException.php
+++ /dev/null
@@ -1,31 +0,0 @@
-
- */
-class ClientException extends Exception
-{
- public function __construct(
- private readonly Error $error,
- ) {
- parent::__construct($error->message);
- }
-
- public function getError(): Error
- {
- return $this->error;
- }
-}
diff --git a/src/Exception/ClientRegistrationException.php b/src/Exception/ClientRegistrationException.php
deleted file mode 100644
index 38711468..00000000
--- a/src/Exception/ClientRegistrationException.php
+++ /dev/null
@@ -1,23 +0,0 @@
-
- */
-class ConfigurationException extends InvalidArgumentException
-{
-}
diff --git a/src/Exception/ConnectionException.php b/src/Exception/ConnectionException.php
deleted file mode 100644
index 4e4527f5..00000000
--- a/src/Exception/ConnectionException.php
+++ /dev/null
@@ -1,21 +0,0 @@
-
- */
-class ConnectionException extends Exception
-{
-}
diff --git a/src/Exception/ContainerException.php b/src/Exception/ContainerException.php
deleted file mode 100644
index 0a56987d..00000000
--- a/src/Exception/ContainerException.php
+++ /dev/null
@@ -1,18 +0,0 @@
-
- */
-final class ElicitationException extends \RuntimeException implements ExceptionInterface
-{
-}
diff --git a/src/Exception/Exception.php b/src/Exception/Exception.php
deleted file mode 100644
index 12ff5f01..00000000
--- a/src/Exception/Exception.php
+++ /dev/null
@@ -1,19 +0,0 @@
-
- */
-class Exception extends \Exception implements ExceptionInterface
-{
-}
diff --git a/src/Exception/ExceptionInterface.php b/src/Exception/ExceptionInterface.php
deleted file mode 100644
index 63449305..00000000
--- a/src/Exception/ExceptionInterface.php
+++ /dev/null
@@ -1,19 +0,0 @@
-
- */
-interface ExceptionInterface extends \Throwable
-{
-}
diff --git a/src/Exception/HandlerNotFoundException.php b/src/Exception/HandlerNotFoundException.php
deleted file mode 100644
index ef222eda..00000000
--- a/src/Exception/HandlerNotFoundException.php
+++ /dev/null
@@ -1,19 +0,0 @@
-
- */
-class HandlerNotFoundException extends \InvalidArgumentException implements NotFoundExceptionInterface
-{
-}
diff --git a/src/Exception/InvalidArgumentException.php b/src/Exception/InvalidArgumentException.php
deleted file mode 100644
index 8c85a7c4..00000000
--- a/src/Exception/InvalidArgumentException.php
+++ /dev/null
@@ -1,19 +0,0 @@
-
- */
-class InvalidArgumentException extends \InvalidArgumentException implements ExceptionInterface
-{
-}
diff --git a/src/Exception/InvalidCursorException.php b/src/Exception/InvalidCursorException.php
deleted file mode 100644
index 5b08743a..00000000
--- a/src/Exception/InvalidCursorException.php
+++ /dev/null
@@ -1,24 +0,0 @@
-
- */
-final class InvalidCursorException extends \InvalidArgumentException implements ExceptionInterface
-{
- public function __construct(
- public readonly string $cursor,
- ) {
- parent::__construct(\sprintf('Invalid value for pagination parameter "cursor": "%s"', $cursor));
- }
-}
diff --git a/src/Exception/InvalidInputMessageException.php b/src/Exception/InvalidInputMessageException.php
deleted file mode 100644
index 4ab485a9..00000000
--- a/src/Exception/InvalidInputMessageException.php
+++ /dev/null
@@ -1,19 +0,0 @@
-
- */
-class InvalidInputMessageException extends \InvalidArgumentException implements ExceptionInterface
-{
-}
diff --git a/src/Exception/LogicException.php b/src/Exception/LogicException.php
deleted file mode 100644
index 2ebe44c9..00000000
--- a/src/Exception/LogicException.php
+++ /dev/null
@@ -1,19 +0,0 @@
-
- */
-final class LogicException extends \LogicException implements ExceptionInterface
-{
-}
diff --git a/src/Exception/NotFoundExceptionInterface.php b/src/Exception/NotFoundExceptionInterface.php
deleted file mode 100644
index faecad67..00000000
--- a/src/Exception/NotFoundExceptionInterface.php
+++ /dev/null
@@ -1,19 +0,0 @@
-
- */
-interface NotFoundExceptionInterface extends ExceptionInterface
-{
-}
diff --git a/src/Exception/PromptGetException.php b/src/Exception/PromptGetException.php
deleted file mode 100644
index 7eec0daf..00000000
--- a/src/Exception/PromptGetException.php
+++ /dev/null
@@ -1,19 +0,0 @@
-
- */
-final class PromptGetException extends \RuntimeException implements ExceptionInterface
-{
-}
diff --git a/src/Exception/PromptNotFoundException.php b/src/Exception/PromptNotFoundException.php
deleted file mode 100644
index 81b7c6e5..00000000
--- a/src/Exception/PromptNotFoundException.php
+++ /dev/null
@@ -1,24 +0,0 @@
-
- */
-final class PromptNotFoundException extends \RuntimeException implements NotFoundExceptionInterface
-{
- public function __construct(
- public readonly string $name,
- ) {
- parent::__construct(\sprintf('Prompt not found: "%s".', $name));
- }
-}
diff --git a/src/Exception/RegistryException.php b/src/Exception/RegistryException.php
deleted file mode 100644
index c483a01e..00000000
--- a/src/Exception/RegistryException.php
+++ /dev/null
@@ -1,35 +0,0 @@
-
- */
-class RequestException extends Exception
-{
- private ?Error $error;
-
- public function __construct(string $message = '', int $code = 0, ?\Throwable $previous = null, ?Error $error = null)
- {
- parent::__construct($message, $code, $previous);
- $this->error = $error;
- }
-
- public static function fromError(Error $error): self
- {
- return new self($error->message, $error->code, null, $error);
- }
-
- public function getError(): ?Error
- {
- return $this->error;
- }
-}
diff --git a/src/Exception/ResourceNotFoundException.php b/src/Exception/ResourceNotFoundException.php
deleted file mode 100644
index 420ac1a8..00000000
--- a/src/Exception/ResourceNotFoundException.php
+++ /dev/null
@@ -1,24 +0,0 @@
-
- */
-final class ResourceNotFoundException extends \RuntimeException implements NotFoundExceptionInterface
-{
- public function __construct(
- public readonly string $uri,
- ) {
- parent::__construct(\sprintf('Resource not found for uri: "%s".', $uri));
- }
-}
diff --git a/src/Exception/ResourceReadException.php b/src/Exception/ResourceReadException.php
deleted file mode 100644
index a89dec8e..00000000
--- a/src/Exception/ResourceReadException.php
+++ /dev/null
@@ -1,19 +0,0 @@
-
- */
-final class ResourceReadException extends \RuntimeException implements ExceptionInterface
-{
-}
diff --git a/src/Exception/RootsException.php b/src/Exception/RootsException.php
deleted file mode 100644
index 8d12cb06..00000000
--- a/src/Exception/RootsException.php
+++ /dev/null
@@ -1,24 +0,0 @@
-
- */
-final class RootsException extends \RuntimeException implements ExceptionInterface
-{
-}
diff --git a/src/Exception/RuntimeException.php b/src/Exception/RuntimeException.php
deleted file mode 100644
index b68a1bf4..00000000
--- a/src/Exception/RuntimeException.php
+++ /dev/null
@@ -1,19 +0,0 @@
-
- */
-final class RuntimeException extends \RuntimeException implements ExceptionInterface
-{
-}
diff --git a/src/Exception/SamplingException.php b/src/Exception/SamplingException.php
deleted file mode 100644
index 17abcebc..00000000
--- a/src/Exception/SamplingException.php
+++ /dev/null
@@ -1,24 +0,0 @@
-
- */
-final class SamplingException extends \RuntimeException implements ExceptionInterface
-{
-}
diff --git a/src/Exception/ServiceNotFoundException.php b/src/Exception/ServiceNotFoundException.php
deleted file mode 100644
index 2d22708a..00000000
--- a/src/Exception/ServiceNotFoundException.php
+++ /dev/null
@@ -1,18 +0,0 @@
-
- */
-class TimeoutException extends Exception
-{
-}
diff --git a/src/Exception/ToolCallException.php b/src/Exception/ToolCallException.php
deleted file mode 100644
index 01ba9f45..00000000
--- a/src/Exception/ToolCallException.php
+++ /dev/null
@@ -1,19 +0,0 @@
-
- */
-final class ToolCallException extends \RuntimeException implements ExceptionInterface
-{
-}
diff --git a/src/Exception/ToolNotFoundException.php b/src/Exception/ToolNotFoundException.php
deleted file mode 100644
index 0a864e75..00000000
--- a/src/Exception/ToolNotFoundException.php
+++ /dev/null
@@ -1,24 +0,0 @@
-
- */
-final class ToolNotFoundException extends \RuntimeException implements NotFoundExceptionInterface
-{
- public function __construct(
- public readonly string $name,
- ) {
- parent::__construct(\sprintf('Tool not found: "%s".', $name));
- }
-}
diff --git a/src/JsonRpc/MessageFactory.php b/src/JsonRpc/MessageFactory.php
deleted file mode 100644
index d9a895ec..00000000
--- a/src/JsonRpc/MessageFactory.php
+++ /dev/null
@@ -1,209 +0,0 @@
-
- * @author Kyrian Obikwelu
- */
-final class MessageFactory
-{
- /**
- * Registry of all known message classes that have methods.
- *
- * @var list|class-string>
- */
- private const REGISTERED_MESSAGES = [
- Schema\Notification\CancelledNotification::class,
- Schema\Notification\InitializedNotification::class,
- Schema\Notification\LoggingMessageNotification::class,
- Schema\Notification\ProgressNotification::class,
- Schema\Notification\PromptListChangedNotification::class,
- Schema\Notification\ResourceListChangedNotification::class,
- Schema\Notification\ResourceUpdatedNotification::class,
- Schema\Notification\RootsListChangedNotification::class,
- Schema\Notification\ToolListChangedNotification::class,
-
- Schema\Request\CallToolRequest::class,
- Schema\Request\CompletionCompleteRequest::class,
- Schema\Request\CreateSamplingMessageRequest::class,
- Schema\Request\ElicitRequest::class,
- Schema\Request\GetPromptRequest::class,
- Schema\Request\InitializeRequest::class,
- Schema\Request\ListPromptsRequest::class,
- Schema\Request\ListResourcesRequest::class,
- Schema\Request\ListResourceTemplatesRequest::class,
- Schema\Request\ListRootsRequest::class,
- Schema\Request\ListToolsRequest::class,
- Schema\Request\PingRequest::class,
- Schema\Request\ReadResourceRequest::class,
- Schema\Request\ResourceSubscribeRequest::class,
- Schema\Request\ResourceUnsubscribeRequest::class,
- Schema\Request\SetLogLevelRequest::class,
- ];
-
- /**
- * Upper bound on the number of messages accepted in a single batch, guarding
- * against amplification where one small request expands into many operations.
- */
- public const DEFAULT_MAX_BATCH_SIZE = 100;
-
- /**
- * @param list|class-string> $registeredMessages
- * @param int $maxBatchSize Maximum number of messages accepted in a single JSON-RPC batch
- */
- public function __construct(
- private readonly array $registeredMessages,
- private readonly int $maxBatchSize = self::DEFAULT_MAX_BATCH_SIZE,
- ) {
- if ($this->maxBatchSize < 1) {
- throw new InvalidArgumentException('maxBatchSize must be at least 1.');
- }
-
- foreach ($this->registeredMessages as $messageClass) {
- if (!is_subclass_of($messageClass, Request::class) && !is_subclass_of($messageClass, Notification::class)) {
- throw new InvalidArgumentException(\sprintf('Message classes must extend %s or %s.', Request::class, Notification::class));
- }
- }
- }
-
- /**
- * Creates a new Factory instance with all the protocol's default messages.
- */
- public static function make(int $maxBatchSize = self::DEFAULT_MAX_BATCH_SIZE): self
- {
- return new self(self::REGISTERED_MESSAGES, $maxBatchSize);
- }
-
- /**
- * Creates message objects from JSON input.
- *
- * Supports both single messages and batch requests. Returns an array containing
- * MessageInterface objects or InvalidInputMessageException instances for invalid messages.
- *
- * @return array
- *
- * @throws \JsonException When the input string is not valid JSON
- */
- public function create(string $input): array
- {
- $data = json_decode($input, true, flags: \JSON_THROW_ON_ERROR);
-
- // A JSON-RPC payload is a single message (JSON object) or a batch (JSON
- // array). Anything else (scalar, null) is invalid input rather than a
- // parse error, and must not reach the per-message loop below.
- if (!\is_array($data)) {
- return [new InvalidInputMessageException('A JSON-RPC message must be a JSON object or a batch array.')];
- }
-
- // json_decode(assoc: true) maps both objects and arrays to PHP arrays. A
- // list is a batch; a non-list (string keys) is a single message. An empty
- // array is ambiguous ({} vs []) and invalid as either, so reject it.
- if ([] === $data) {
- return [new InvalidInputMessageException('A JSON-RPC message must not be empty.')];
- }
-
- if (array_is_list($data)) {
- if (\count($data) > $this->maxBatchSize) {
- return [new InvalidInputMessageException(\sprintf('JSON-RPC batch size %d exceeds the maximum allowed batch size of %d.', \count($data), $this->maxBatchSize))];
- }
-
- $batch = $data;
- } else {
- $batch = [$data];
- }
-
- $messages = [];
- foreach ($batch as $message) {
- try {
- if (!\is_array($message)) {
- throw new InvalidInputMessageException('A JSON-RPC message must be a JSON object.');
- }
-
- $messages[] = $this->createMessage($message);
- } catch (InvalidInputMessageException $e) {
- $messages[] = $e;
- }
- }
-
- return $messages;
- }
-
- /**
- * Creates a single message object from parsed JSON data.
- *
- * @param array $data
- *
- * @throws InvalidInputMessageException
- */
- private function createMessage(array $data): MessageInterface
- {
- try {
- if (isset($data['error'])) {
- return Error::fromArray($data);
- }
-
- if (isset($data['result'])) {
- return Response::fromArray($data);
- }
-
- if (!isset($data['method'])) {
- throw new InvalidInputMessageException('Invalid JSON-RPC message: missing "method", "result", or "error" field.');
- }
-
- if (!\is_string($data['method'])) {
- throw new InvalidInputMessageException('Invalid JSON-RPC message: "method" must be a string.');
- }
-
- $messageClass = $this->findMessageClassByMethod($data['method']);
-
- return $messageClass::fromArray($data);
- } catch (InvalidArgumentException $e) {
- throw new InvalidInputMessageException($e->getMessage(), 0, $e);
- }
- }
-
- /**
- * Finds the registered message class for a given method name.
- *
- * @return class-string|class-string
- *
- * @throws InvalidInputMessageException
- */
- private function findMessageClassByMethod(string $method): string
- {
- foreach ($this->registeredMessages as $messageClass) {
- if ($messageClass::getMethod() === $method) {
- return $messageClass;
- }
- }
-
- throw new InvalidInputMessageException(\sprintf('Unknown method "%s".', $method));
- }
-}
diff --git a/src/Schema/Annotations.php b/src/Schema/Annotations.php
deleted file mode 100644
index ec64e7ab..00000000
--- a/src/Schema/Annotations.php
+++ /dev/null
@@ -1,118 +0,0 @@
-
- */
-class Annotations implements \JsonSerializable
-{
- /**
- * @param Role[]|null $audience Describes who the intended customer of this object or data is.
- *
- * It can include multiple entries to indicate content useful for multiple audiences (e.g., `[Role::User, Role::Assistant]`).
- * @param float|null $priority Describes how important this data is for operating the server.
- *
- * A value of 1 means "most important," and indicates that the data is
- * effectively required, while 0 means "least important," and indicates that
- * the data is entirely optional.
- */
- public function __construct(
- public readonly ?array $audience = null,
- public readonly ?float $priority = null,
- ) {
- if (null !== $this->priority && ($this->priority < 0 || $this->priority > 1)) {
- throw new InvalidArgumentException('Annotation priority must be between 0 and 1.');
- }
- if (null !== $this->audience) {
- foreach ($this->audience as $role) {
- if (!$role instanceof Role) {
- throw new InvalidArgumentException('All audience members must be instances of Role enum.');
- }
- }
- }
- }
-
- /**
- * @param AnnotationsData $data
- */
- public static function fromArray(array $data): self
- {
- $audience = null;
- if (isset($data['audience']) && \is_array($data['audience'])) {
- $audience = array_map(
- static function (mixed $role): Role {
- if (!\is_string($role) || null === $case = Role::tryFrom($role)) {
- throw new InvalidArgumentException('Each entry in "audience" must be a valid role.');
- }
-
- return $case;
- },
- $data['audience'],
- );
- }
-
- if (isset($data['priority']) && !\is_float($data['priority']) && !\is_int($data['priority'])) {
- throw new InvalidArgumentException('Invalid "priority" in Annotations data; expected a number.');
- }
-
- return new self(
- $audience,
- isset($data['priority']) ? (float) $data['priority'] : null
- );
- }
-
- /**
- * Hydrates an optional "annotations" field, rejecting a value that is present but not an object.
- *
- * @param string $context the surrounding schema type, used for the error message
- */
- public static function tryFromArray(mixed $data, string $context): ?self
- {
- if (null === $data) {
- return null;
- }
-
- if (!\is_array($data)) {
- throw new InvalidArgumentException(\sprintf('Invalid "annotations" in %s data; expected an array.', $context));
- }
-
- return self::fromArray($data);
- }
-
- /**
- * @return AnnotationsData
- */
- public function jsonSerialize(): array
- {
- $data = [];
- if (null !== $this->audience) {
- $data['audience'] = array_map(static fn (Role $r) => $r->value, $this->audience);
- }
- if (null !== $this->priority) {
- $data['priority'] = $this->priority;
- }
-
- return $data;
- }
-}
diff --git a/src/Schema/ClientCapabilities.php b/src/Schema/ClientCapabilities.php
deleted file mode 100644
index 5eab9e66..00000000
--- a/src/Schema/ClientCapabilities.php
+++ /dev/null
@@ -1,140 +0,0 @@
-
- */
-class ClientCapabilities implements \JsonSerializable
-{
- /**
- * @param array $experimental
- * @param ?array $extensions protocol extensions the client supports (e.g. io.modelcontextprotocol/ui)
- * @param ?bool $samplingContext the `sampling.context` sub-capability
- * @param ?bool $samplingTools the `sampling.tools` sub-capability
- *
- * The two sampling sub-capabilities trail `extensions` rather than sitting next to
- * `sampling` so that existing positional calls keep working. Pass them by name.
- */
- public function __construct(
- public readonly ?bool $roots = false,
- public readonly ?bool $rootsListChanged = null,
- public readonly ?bool $sampling = null,
- public readonly ?bool $elicitation = null,
- public readonly ?array $experimental = null,
- public readonly ?array $extensions = null,
- public readonly ?bool $samplingContext = null,
- public readonly ?bool $samplingTools = null,
- ) {
- }
-
- /**
- * @param array{
- * roots?: array{
- * listChanged?: bool,
- * },
- * sampling?: array{context?: mixed, tools?: mixed}|object,
- * elicitation?: bool,
- * experimental?: array,
- * extensions?: array,
- * } $data
- */
- public static function fromArray(array $data): self
- {
- $rootsEnabled = isset($data['roots']);
- $rootsListChanged = null;
- if ($rootsEnabled) {
- if (\is_array($data['roots']) && \array_key_exists('listChanged', $data['roots'])) {
- $rootsListChanged = (bool) $data['roots']['listChanged'];
- } elseif (\is_object($data['roots']) && property_exists($data['roots'], 'listChanged')) {
- $rootsListChanged = (bool) $data['roots']->listChanged;
- }
- }
-
- $sampling = null;
- $samplingContext = null;
- $samplingTools = null;
- if (isset($data['sampling'])) {
- $sampling = true;
- if (\is_array($data['sampling'])) {
- $samplingContext = isset($data['sampling']['context']);
- $samplingTools = isset($data['sampling']['tools']);
- } elseif (\is_object($data['sampling'])) {
- $samplingContext = property_exists($data['sampling'], 'context');
- $samplingTools = property_exists($data['sampling'], 'tools');
- }
- }
-
- $elicitation = null;
- if (isset($data['elicitation'])) {
- $elicitation = true;
- }
-
- return new self(
- $rootsEnabled,
- $rootsListChanged,
- $sampling,
- $elicitation,
- \is_array($data['experimental'] ?? null) ? $data['experimental'] : null,
- \is_array($data['extensions'] ?? null) ? $data['extensions'] : null,
- $samplingContext,
- $samplingTools,
- );
- }
-
- /**
- * @return array{
- * roots?: object,
- * sampling?: object,
- * elicitation?: object,
- * experimental?: object,
- * extensions?: object,
- * }|\stdClass
- */
- public function jsonSerialize(): array|object
- {
- $data = [];
- if ($this->roots || $this->rootsListChanged) {
- $data['roots'] = new \stdClass();
- if ($this->rootsListChanged) {
- $data['roots']->listChanged = $this->rootsListChanged;
- }
- }
-
- if ($this->sampling || $this->samplingContext || $this->samplingTools) {
- $data['sampling'] = new \stdClass();
- if ($this->samplingContext) {
- $data['sampling']->context = new \stdClass();
- }
- if ($this->samplingTools) {
- $data['sampling']->tools = new \stdClass();
- }
- }
-
- if ($this->elicitation) {
- $data['elicitation'] = new \stdClass();
- }
-
- if ($this->experimental) {
- $data['experimental'] = (object) $this->experimental;
- }
-
- if ($this->extensions) {
- $data['extensions'] = (object) $this->extensions;
- }
-
- return $data ?: new \stdClass();
- }
-}
diff --git a/src/Schema/Content/AudioContent.php b/src/Schema/Content/AudioContent.php
deleted file mode 100644
index cf7418c5..00000000
--- a/src/Schema/Content/AudioContent.php
+++ /dev/null
@@ -1,120 +0,0 @@
-
- */
-class AudioContent extends Content
-{
- public function __construct(
- public readonly string $data,
- public readonly string $mimeType,
- public readonly ?Annotations $annotations = null,
- ) {
- parent::__construct('audio');
- }
-
- /**
- * @param AudioContentData $data
- */
- public static function fromArray(array $data): self
- {
- if (!isset($data['data']) || !\is_string($data['data'])) {
- throw new InvalidArgumentException('Missing or invalid "data" in AudioContent data.');
- }
- if (!isset($data['mimeType']) || !\is_string($data['mimeType'])) {
- throw new InvalidArgumentException('Missing or invalid "mimeType" in AudioContent data.');
- }
-
- return new self(
- $data['data'],
- $data['mimeType'],
- Annotations::tryFromArray($data['annotations'] ?? null, 'AudioContent')
- );
- }
-
- /**
- * Create a new AudioContent from a file path.
- *
- * @param string $path Path to the audio file
- * @param string|null $mimeType Optional MIME type override
- * @param ?Annotations $annotations Optional annotations describing the content
- *
- * @throws InvalidArgumentException If the file doesn't exist
- */
- public static function fromFile(string $path, ?string $mimeType = null, ?Annotations $annotations = null): self
- {
- if (!file_exists($path)) {
- throw new InvalidArgumentException(\sprintf('Audio file not found: "%s".', $path));
- }
-
- $content = file_get_contents($path);
- if (false === $content) {
- throw new RuntimeException(\sprintf('Could not read audio file: "%s".', $path));
- }
- $data = base64_encode($content);
- $detectedMime = $mimeType ?? mime_content_type($path) ?: 'application/octet-stream';
-
- return new self($data, $detectedMime, $annotations);
- }
-
- /**
- * Create a new AudioContent from a string.
- *
- * @param string $data The audio data
- * @param string $mimeType MIME type of the audio
- * @param ?Annotations $annotations Optional annotations describing the content
- */
- public static function fromString(string $data, string $mimeType, ?Annotations $annotations = null): self
- {
- return new self(base64_encode($data), $mimeType, $annotations);
- }
-
- /**
- * @return array{
- * type: 'audio',
- * data: string,
- * mimeType: string,
- * annotations?: Annotations,
- * }
- */
- public function jsonSerialize(): array
- {
- $result = [
- 'type' => 'audio',
- 'data' => $this->data,
- 'mimeType' => $this->mimeType,
- ];
-
- if (null !== $this->annotations) {
- $result['annotations'] = $this->annotations;
- }
-
- return $result;
- }
-}
diff --git a/src/Schema/Content/BlobResourceContents.php b/src/Schema/Content/BlobResourceContents.php
deleted file mode 100644
index ee126fbc..00000000
--- a/src/Schema/Content/BlobResourceContents.php
+++ /dev/null
@@ -1,99 +0,0 @@
-
- * }
- *
- * @author Kyrian Obikwelu
- */
-class BlobResourceContents extends ResourceContents
-{
- /**
- * @param string $uri the URI of the resource or sub-resource
- * @param string|null $mimeType the MIME type of the resource or sub-resource
- * @param string $blob a base64-encoded string representing the binary data of the item
- * @param ?array $meta Optional metadata
- */
- public function __construct(
- string $uri,
- ?string $mimeType,
- public readonly string $blob,
- ?array $meta = null,
- ) {
- parent::__construct($uri, $mimeType, $meta);
- }
-
- /**
- * @param BlobResourceContentsData $data
- */
- public static function fromArray(array $data): self
- {
- if (!isset($data['uri']) || !\is_string($data['uri'])) {
- throw new InvalidArgumentException('Missing or invalid "uri" for BlobResourceContents.');
- }
- if (!isset($data['blob']) || !\is_string($data['blob'])) {
- throw new InvalidArgumentException('Missing or invalid "blob" for BlobResourceContents.');
- }
-
- if (isset($data['mimeType']) && !\is_string($data['mimeType'])) {
- throw new InvalidArgumentException('Invalid "mimeType" for BlobResourceContents.');
- }
- if (isset($data['_meta']) && !\is_array($data['_meta'])) {
- throw new InvalidArgumentException('Invalid "_meta" for BlobResourceContents.');
- }
-
- return new self($data['uri'], $data['mimeType'] ?? null, $data['blob'], $data['_meta'] ?? null);
- }
-
- /**
- * @param resource $stream
- * @param ?array $meta Optional metadata
- * */
- public static function fromStream(string $uri, $stream, string $mimeType, ?array $meta = null): self
- {
- $blob = stream_get_contents($stream);
-
- return new self($uri, $mimeType, base64_encode($blob), $meta);
- }
-
- /**
- * @param ?array $meta Optional metadata
- * */
- public static function fromSplFileInfo(string $uri, \SplFileInfo $file, ?string $explicitMimeType = null, ?array $meta = null): self
- {
- $mimeType = $explicitMimeType ?? mime_content_type($file->getPathname());
- $blob = file_get_contents($file->getPathname());
-
- return new self($uri, $mimeType, base64_encode($blob), $meta);
- }
-
- /**
- * @return BlobResourceContentsData
- */
- public function jsonSerialize(): array
- {
- return [
- 'blob' => $this->blob,
- ...parent::jsonSerialize(),
- ];
- }
-}
diff --git a/src/Schema/Content/Content.php b/src/Schema/Content/Content.php
deleted file mode 100644
index f1ac3948..00000000
--- a/src/Schema/Content/Content.php
+++ /dev/null
@@ -1,25 +0,0 @@
-
- */
-abstract class Content implements \JsonSerializable
-{
- public function __construct(
- public readonly string $type,
- ) {
- }
-}
diff --git a/src/Schema/Content/EmbeddedResource.php b/src/Schema/Content/EmbeddedResource.php
deleted file mode 100644
index 76c4d679..00000000
--- a/src/Schema/Content/EmbeddedResource.php
+++ /dev/null
@@ -1,152 +0,0 @@
-
- */
-class EmbeddedResource extends Content
-{
- public function __construct(
- public readonly TextResourceContents|BlobResourceContents $resource,
- public readonly ?Annotations $annotations = null,
- ) {
- parent::__construct('resource');
- }
-
- /**
- * @param EmbeddedResourceData $data
- */
- public static function fromArray(array $data): self
- {
- if (($data['type'] ?? null) !== 'resource') {
- throw new InvalidArgumentException('Invalid type for EmbeddedResource.');
- }
- if (!isset($data['resource']) || !\is_array($data['resource'])) {
- throw new InvalidArgumentException('Missing or invalid "resource" field for EmbeddedResource.');
- }
-
- $resourceData = $data['resource'];
- if (isset($resourceData['text'])) {
- $resourceInstance = TextResourceContents::fromArray($resourceData);
- } elseif (isset($resourceData['blob'])) {
- $resourceInstance = BlobResourceContents::fromArray($resourceData);
- } else {
- throw new InvalidArgumentException('EmbeddedResource "resource" field must contain "text" or "blob".');
- }
-
- return new self(
- $resourceInstance,
- Annotations::tryFromArray($data['annotations'] ?? null, 'EmbeddedResource'),
- );
- }
-
- public static function fromText(string $uri, string $text, ?string $mimeType = 'text/plain', ?Annotations $annotations = null): self
- {
- $textContent = new TextResourceContents($uri, $mimeType, $text);
-
- return new self($textContent, $annotations);
- }
-
- public static function fromBlob(string $uri, string $base64Blob, string $mimeType, ?Annotations $annotations = null): self
- {
- $blobContent = new BlobResourceContents($uri, $mimeType, $base64Blob);
-
- return new self($blobContent, $annotations);
- }
-
- public static function fromFile(string $uri, string $path, ?string $explicitMimeType = null, ?Annotations $annotations = null): self
- {
- if (!file_exists($path) || !is_readable($path)) {
- throw new InvalidArgumentException(\sprintf('File not found or not readable: "%s".', $path));
- }
- $content = file_get_contents($path);
- if (false === $content) {
- throw new RuntimeException(\sprintf('Could not read file: "%s".', $path));
- }
-
- $guessedMimeType = $explicitMimeType ?? mime_content_type($path) ?: 'application/octet-stream';
-
- if (self::isTextMimeTypeHeuristic($guessedMimeType) && mb_check_encoding($content, 'UTF-8')) {
- $resourceContent = new TextResourceContents($uri, $guessedMimeType, $content);
- } else {
- $resourceContent = new BlobResourceContents($uri, $guessedMimeType, base64_encode($content));
- }
-
- return new self($resourceContent, $annotations);
- }
-
- /**
- * @param resource $stream
- */
- public static function fromStream(string $uri, $stream, string $mimeType, ?Annotations $annotations = null): self
- {
- $content = stream_get_contents($stream);
- if (false === $content) {
- throw new RuntimeException('Could not read stream.');
- }
-
- return new self(new BlobResourceContents($uri, $mimeType, base64_encode($content)), $annotations);
- }
-
- public static function fromSplFileInfo(string $uri, \SplFileInfo $file, ?string $explicitMimeType = null, ?Annotations $annotations = null): self
- {
- $content = file_get_contents($file->getPathname());
- if (false === $content) {
- throw new RuntimeException(\sprintf('Could not read file: "%s".', $file->getPathname()));
- }
-
- return new self(new BlobResourceContents($uri, $explicitMimeType ?? mime_content_type($file->getPathname()), base64_encode($content)), $annotations);
- }
-
- /**
- * @return array{
- * type: 'resource',
- * resource: TextResourceContents|BlobResourceContents,
- * annotations?: Annotations,
- * }
- */
- public function jsonSerialize(): array
- {
- $data = [
- 'type' => $this->type,
- 'resource' => $this->resource,
- ];
- if (null !== $this->annotations) {
- $data['annotations'] = $this->annotations;
- }
-
- return $data;
- }
-
- private static function isTextMimeTypeHeuristic(string $mimeType): bool
- {
- return str_starts_with($mimeType, 'text/')
- || \in_array(strtolower($mimeType), ['application/json', 'application/xml', 'application/javascript', 'application/yaml']);
- }
-}
diff --git a/src/Schema/Content/ImageContent.php b/src/Schema/Content/ImageContent.php
deleted file mode 100644
index 9e2cbffd..00000000
--- a/src/Schema/Content/ImageContent.php
+++ /dev/null
@@ -1,115 +0,0 @@
-
- */
-class ImageContent extends Content
-{
- /**
- * Create a new ImageContent instance.
- *
- * @param string $data Base64-encoded image data
- * @param string $mimeType The MIME type of the image
- * @param ?Annotations $annotations Optional annotations describing the content
- */
- public function __construct(
- public readonly string $data,
- public readonly string $mimeType,
- public readonly ?Annotations $annotations = null,
- ) {
- parent::__construct('image');
- }
-
- /**
- * @param ImageContentData $data
- */
- public static function fromArray(array $data): self
- {
- if (!isset($data['data']) || !\is_string($data['data'])) {
- throw new InvalidArgumentException('Missing or invalid "data" in ImageContent data.');
- }
- if (!isset($data['mimeType']) || !\is_string($data['mimeType'])) {
- throw new InvalidArgumentException('Missing or invalid "mimeType" in ImageContent data.');
- }
-
- return new self(
- $data['data'],
- $data['mimeType'],
- isset($data['annotations']) ? Annotations::fromArray($data['annotations']) : null
- );
- }
-
- /**
- * Create a new ImageContent from a file path.
- *
- * @param string $path Path to the image file
- * @param string|null $mimeType Optional MIME type override
- * @param ?Annotations $annotations Optional annotations describing the content
- *
- * @throws InvalidArgumentException If the file doesn't exist
- */
- public static function fromFile(string $path, ?string $mimeType = null, ?Annotations $annotations = null): self
- {
- if (!file_exists($path)) {
- throw new InvalidArgumentException(\sprintf('Image file not found: "%s".', $path));
- }
-
- $data = base64_encode(file_get_contents($path));
- $detectedMime = $mimeType ?? mime_content_type($path) ?: 'image/png';
-
- return new self($data, $detectedMime, $annotations);
- }
-
- public static function fromString(string $data, string $mimeType, ?Annotations $annotations = null): self
- {
- return new self(base64_encode($data), $mimeType, $annotations);
- }
-
- /**
- * Convert the content to an array.
- *
- * @return array{
- * type: 'image',
- * data: string,
- * mimeType: string,
- * annotations?: Annotations,
- * }
- */
- public function jsonSerialize(): array
- {
- $result = [
- 'type' => $this->type,
- 'data' => $this->data,
- 'mimeType' => $this->mimeType,
- ];
-
- if (null !== $this->annotations) {
- $result['annotations'] = $this->annotations;
- }
-
- return $result;
- }
-}
diff --git a/src/Schema/Content/PromptMessage.php b/src/Schema/Content/PromptMessage.php
deleted file mode 100644
index d47f5ec9..00000000
--- a/src/Schema/Content/PromptMessage.php
+++ /dev/null
@@ -1,97 +0,0 @@
-
- */
-class PromptMessage extends Content
-{
- /**
- * Create a new PromptMessage instance.
- *
- * @param Role $role The role of the message
- * @param TextContent|ImageContent|AudioContent|ResourceLink|EmbeddedResource $content The content of the message
- */
- public function __construct(
- public readonly Role $role,
- public readonly TextContent|ImageContent|AudioContent|ResourceLink|EmbeddedResource $content,
- ) {
- parent::__construct('prompt');
- }
-
- /**
- * @param PromptMessageData $data
- */
- public static function fromArray(array $data): self
- {
- if (!isset($data['role']) || !\is_string($data['role'])) {
- throw new InvalidArgumentException('Missing or invalid "role" in PromptMessage data.');
- }
- if (!isset($data['content']) || !\is_array($data['content'])) {
- throw new InvalidArgumentException('Missing or invalid "content" in PromptMessage data.');
- }
-
- $contentData = $data['content'];
- $contentType = $contentData['type'] ?? null;
- if (!\is_string($contentType)) {
- throw new InvalidArgumentException('Missing or invalid content "type" for PromptMessage.');
- }
-
- $content = match ($contentType) {
- 'text' => TextContent::fromArray($contentData),
- 'image' => ImageContent::fromArray($contentData),
- 'audio' => AudioContent::fromArray($contentData),
- 'resource' => EmbeddedResource::fromArray($contentData),
- 'resource_link' => ResourceLink::fromArray($contentData),
- default => throw new InvalidArgumentException(\sprintf('Invalid content type "%s" for PromptMessage.', $contentType)),
- };
-
- if (null === $role = Role::tryFrom($data['role'])) {
- throw new InvalidArgumentException(\sprintf('Invalid "role" value "%s" in PromptMessage data.', $data['role']));
- }
-
- return new self($role, $content);
- }
-
- /**
- * Convert the message to an array.
- *
- * @return array{
- * role: string,
- * content: TextContent|ImageContent|AudioContent|ResourceLink|EmbeddedResource
- * }
- */
- public function jsonSerialize(): array
- {
- return [
- 'role' => $this->role->value,
- 'content' => $this->content,
- ];
- }
-}
diff --git a/src/Schema/Content/ResourceContents.php b/src/Schema/Content/ResourceContents.php
deleted file mode 100644
index ffd5599b..00000000
--- a/src/Schema/Content/ResourceContents.php
+++ /dev/null
@@ -1,55 +0,0 @@
-
- * }
- *
- * @author Kyrian Obikwelu
- */
-abstract class ResourceContents implements \JsonSerializable
-{
- /**
- * @param string $uri the URI of the resource or sub-resource
- * @param string|null $mimeType the MIME type of the resource or sub-resource
- * @param ?array $meta Optional metadata
- */
- public function __construct(
- public readonly string $uri,
- public readonly ?string $mimeType = null,
- public readonly ?array $meta = null,
- ) {
- }
-
- /**
- * @return ResourceContentsData
- */
- public function jsonSerialize(): array
- {
- $data = ['uri' => $this->uri];
- if (null !== $this->mimeType) {
- $data['mimeType'] = $this->mimeType;
- }
-
- if (null !== $this->meta) {
- $data['_meta'] = $this->meta;
- }
-
- return $data;
- }
-}
diff --git a/src/Schema/Content/ResourceLink.php b/src/Schema/Content/ResourceLink.php
deleted file mode 100644
index 946c9b82..00000000
--- a/src/Schema/Content/ResourceLink.php
+++ /dev/null
@@ -1,155 +0,0 @@
-,
- * }
- *
- * @author Alex Rothberg
- */
-class ResourceLink extends Content
-{
- /**
- * @param string $uri the URI of this resource
- * @param string $name a short identifier for this resource
- * @param ?string $title optional human-readable title for display in UI
- * @param ?string $description a description of what this resource represents. This can be used by clients to improve the LLM's understanding of available resources
- * @param ?string $mimeType the MIME type of this resource, if known
- * @param ?Annotations $annotations optional annotations for the client
- * @param ?int $size the size of the raw resource content, in bytes (before base64 encoding or any tokenization), if known
- * @param ?Icon[] $icons optional icons representing the resource
- * @param ?array $meta optional metadata
- */
- public function __construct(
- public readonly string $uri,
- public readonly string $name,
- public readonly ?string $title = null,
- public readonly ?string $description = null,
- public readonly ?string $mimeType = null,
- public readonly ?Annotations $annotations = null,
- public readonly ?int $size = null,
- public readonly ?array $icons = null,
- public readonly ?array $meta = null,
- ) {
- parent::__construct('resource_link');
- }
-
- /**
- * @param ResourceLinkData $data
- */
- public static function fromArray(array $data): self
- {
- if (($data['type'] ?? null) !== 'resource_link') {
- throw new InvalidArgumentException('Invalid type for ResourceLink.');
- }
- if (empty($data['uri']) || !\is_string($data['uri'])) {
- throw new InvalidArgumentException('Invalid or missing "uri" in ResourceLink data.');
- }
- if (empty($data['name']) || !\is_string($data['name'])) {
- throw new InvalidArgumentException('Invalid or missing "name" in ResourceLink data.');
- }
- if (isset($data['_meta']) && !\is_array($data['_meta'])) {
- throw new InvalidArgumentException('Invalid "_meta" in ResourceLink data.');
- }
- if (isset($data['description']) && !\is_string($data['description'])) {
- throw new InvalidArgumentException('Invalid "description" in ResourceLink data.');
- }
- if (isset($data['mimeType']) && !\is_string($data['mimeType'])) {
- throw new InvalidArgumentException('Invalid "mimeType" in ResourceLink data.');
- }
- if (isset($data['size']) && !\is_int($data['size'])) {
- throw new InvalidArgumentException('Invalid "size" in ResourceLink data; expected an integer.');
- }
-
- return new self(
- uri: $data['uri'],
- name: $data['name'],
- title: isset($data['title']) && \is_string($data['title']) ? $data['title'] : null,
- description: $data['description'] ?? null,
- mimeType: $data['mimeType'] ?? null,
- annotations: Annotations::tryFromArray($data['annotations'] ?? null, 'ResourceLink'),
- size: $data['size'] ?? null,
- icons: isset($data['icons']) && \is_array($data['icons']) ? Icon::listFromArray($data['icons'], 'ResourceLink') : null,
- meta: $data['_meta'] ?? null,
- );
- }
-
- /**
- * @return array{
- * type: 'resource_link',
- * uri: string,
- * name: string,
- * title?: string,
- * description?: string,
- * mimeType?: string,
- * annotations?: Annotations,
- * size?: int,
- * icons?: Icon[],
- * _meta?: array,
- * }
- */
- public function jsonSerialize(): array
- {
- $data = [
- 'type' => $this->type,
- 'uri' => $this->uri,
- 'name' => $this->name,
- ];
- if (null !== $this->title) {
- $data['title'] = $this->title;
- }
- if (null !== $this->description) {
- $data['description'] = $this->description;
- }
- if (null !== $this->mimeType) {
- $data['mimeType'] = $this->mimeType;
- }
- if (null !== $this->annotations) {
- $data['annotations'] = $this->annotations;
- }
- if (null !== $this->size) {
- $data['size'] = $this->size;
- }
- if (null !== $this->icons) {
- $data['icons'] = $this->icons;
- }
- if (null !== $this->meta) {
- $data['_meta'] = $this->meta;
- }
-
- return $data;
- }
-}
diff --git a/src/Schema/Content/SamplingMessage.php b/src/Schema/Content/SamplingMessage.php
deleted file mode 100644
index fc6885b0..00000000
--- a/src/Schema/Content/SamplingMessage.php
+++ /dev/null
@@ -1,159 +0,0 @@
-|list>,
- * _meta?: array,
- * }
- *
- * @author Kyrian Obikwelu
- */
-class SamplingMessage extends Content
-{
- /**
- * @var SamplingContent|list
- */
- public readonly TextContent|ImageContent|AudioContent|ToolUseContent|ToolResultContent|array $content;
-
- /**
- * @param SamplingContent|array $content keys are discarded, the property always holds a list
- * @param ?array $meta
- */
- public function __construct(
- public readonly Role $role,
- TextContent|ImageContent|AudioContent|ToolUseContent|ToolResultContent|array $content,
- public readonly ?array $meta = null,
- ) {
- if (\is_array($content)) {
- if ([] === $content) {
- throw new InvalidArgumentException('Sampling message content must not be empty.');
- }
-
- foreach ($content as $item) {
- if (!$item instanceof TextContent && !$item instanceof ImageContent && !$item instanceof AudioContent && !$item instanceof ToolUseContent && !$item instanceof ToolResultContent) {
- throw new InvalidArgumentException('Sampling message content contains an unsupported content block.');
- }
- }
-
- // array_filter() and friends preserve keys, and a keyed array serializes
- // as a JSON object rather than the array the schema requires.
- $content = array_values($content);
- }
-
- $this->content = $content;
-
- parent::__construct('sampling');
- }
-
- /**
- * @return list
- */
- public function getContentBlocks(): array
- {
- return \is_array($this->content) ? $this->content : [$this->content];
- }
-
- /**
- * @param SamplingMessageData $data
- */
- public static function fromArray(array $data): self
- {
- if (!isset($data['role']) || !\is_string($data['role'])) {
- throw new InvalidArgumentException('Missing or invalid "role" in SamplingMessage data.');
- }
- if (!isset($data['content']) || !\is_array($data['content']) || [] === $data['content']) {
- throw new InvalidArgumentException('Missing or invalid "content" in SamplingMessage data.');
- }
-
- if (null === $role = Role::tryFrom($data['role'])) {
- throw new InvalidArgumentException(\sprintf('Invalid "role" value "%s" in SamplingMessage data.', $data['role']));
- }
-
- $contentData = $data['content'];
- $contentType = $contentData['type'] ?? null;
- if (null !== $contentType && !\is_string($contentType)) {
- throw new InvalidArgumentException('Missing or invalid content "type" for SamplingMessage.');
- }
-
- $isSingleContent = null !== $contentType;
- $contentItems = $isSingleContent ? [$contentData] : $contentData;
- $content = [];
-
- foreach ($contentItems as $item) {
- if (!\is_array($item)) {
- throw new InvalidArgumentException('Invalid content block in SamplingMessage data.');
- }
- $content[] = self::hydrateContent($item);
- }
-
- return new self(
- $role,
- $isSingleContent ? $content[0] : $content,
- isset($data['_meta']) && \is_array($data['_meta']) ? $data['_meta'] : null,
- );
- }
-
- /**
- * @return array{
- * role: string,
- * content: SamplingContent|list,
- * _meta?: array,
- * }
- */
- public function jsonSerialize(): array
- {
- $data = [
- 'role' => $this->role->value,
- 'content' => $this->content,
- ];
-
- if (null !== $this->meta) {
- $data['_meta'] = $this->meta;
- }
-
- return $data;
- }
-
- /**
- * @param array $contentData
- *
- * @return SamplingContent
- */
- private static function hydrateContent(array $contentData): TextContent|ImageContent|AudioContent|ToolUseContent|ToolResultContent
- {
- $contentType = $contentData['type'] ?? null;
-
- return match ($contentType) {
- 'text' => TextContent::fromArray($contentData),
- 'image' => ImageContent::fromArray($contentData),
- 'audio' => AudioContent::fromArray($contentData),
- 'tool_use' => ToolUseContent::fromArray($contentData),
- 'tool_result' => ToolResultContent::fromArray($contentData),
- default => throw new InvalidArgumentException(\sprintf('Invalid content type "%s" for SamplingMessage.', $contentType)),
- };
- }
-}
diff --git a/src/Schema/Content/TextContent.php b/src/Schema/Content/TextContent.php
deleted file mode 100644
index b743af57..00000000
--- a/src/Schema/Content/TextContent.php
+++ /dev/null
@@ -1,96 +0,0 @@
-
- */
-class TextContent extends Content
-{
- /**
- * Create a new TextContent instance from any value.
- *
- * @param mixed $text The value to convert to text
- * @param ?Annotations $annotations Optional annotations describing the content
- */
- public function __construct(
- public mixed $text,
- public readonly ?Annotations $annotations = null,
- ) {
- $this->text = (\is_array($text) || \is_object($text))
- ? json_encode($text, \JSON_PRETTY_PRINT) : (string) $text;
-
- parent::__construct('text');
- }
-
- /**
- * @param TextContentData $data
- */
- public static function fromArray(array $data): self
- {
- if (!isset($data['text']) || !\is_string($data['text'])) {
- throw new InvalidArgumentException('Missing or invalid "text" in TextContent data.');
- }
-
- return new self(
- $data['text'],
- Annotations::tryFromArray($data['annotations'] ?? null, 'TextContent')
- );
- }
-
- /**
- * Create a new TextContent with markdown formatted code.
- *
- * @param string $code The code to format
- * @param string $language The language for syntax highlighting
- */
- public static function code(string $code, string $language = '', ?Annotations $annotations = null): self
- {
- return new self("```{$language}\n{$code}\n```", $annotations);
- }
-
- /**
- * Convert the content to an array.
- *
- * @return array{
- * type: 'text',
- * text: string,
- * annotations?: Annotations,
- * }
- */
- public function jsonSerialize(): array
- {
- $result = [
- 'type' => 'text',
- 'text' => $this->text,
- ];
-
- if (null !== $this->annotations) {
- $result['annotations'] = $this->annotations;
- }
-
- return $result;
- }
-}
diff --git a/src/Schema/Content/TextResourceContents.php b/src/Schema/Content/TextResourceContents.php
deleted file mode 100644
index 9beff811..00000000
--- a/src/Schema/Content/TextResourceContents.php
+++ /dev/null
@@ -1,77 +0,0 @@
-
- * }
- *
- * @author Kyrian Obikwelu
- */
-class TextResourceContents extends ResourceContents
-{
- /**
- * @param string $uri the URI of the resource or sub-resource
- * @param string|null $mimeType the MIME type of the resource or sub-resource
- * @param string $text The text of the item. This must only be set if the item can actually be represented as text (not binary data).
- * @param ?array $meta Optional metadata
- */
- public function __construct(
- string $uri,
- ?string $mimeType,
- public readonly string $text,
- ?array $meta = null,
- ) {
- parent::__construct($uri, $mimeType, $meta);
- }
-
- /**
- * @param TextResourceContentsData $data
- */
- public static function fromArray(array $data): self
- {
- if (!isset($data['uri']) || !\is_string($data['uri'])) {
- throw new InvalidArgumentException('Missing or invalid "uri" for TextResourceContents.');
- }
- if (!isset($data['text']) || !\is_string($data['text'])) {
- throw new InvalidArgumentException('Missing or invalid "text" for TextResourceContents.');
- }
-
- if (isset($data['mimeType']) && !\is_string($data['mimeType'])) {
- throw new InvalidArgumentException('Invalid "mimeType" for TextResourceContents.');
- }
- if (isset($data['_meta']) && !\is_array($data['_meta'])) {
- throw new InvalidArgumentException('Invalid "_meta" for TextResourceContents.');
- }
-
- return new self($data['uri'], $data['mimeType'] ?? null, $data['text'], $data['_meta'] ?? null);
- }
-
- /**
- * @return TextResourceContentsData
- */
- public function jsonSerialize(): array
- {
- return [
- 'text' => $this->text,
- ...parent::jsonSerialize(),
- ];
- }
-}
diff --git a/src/Schema/Content/ToolResultContent.php b/src/Schema/Content/ToolResultContent.php
deleted file mode 100644
index 5e0e9b20..00000000
--- a/src/Schema/Content/ToolResultContent.php
+++ /dev/null
@@ -1,118 +0,0 @@
-
- */
- public readonly array $content;
-
- /**
- * @param array $content keys are discarded, the property always holds a list
- * @param ?array $structuredContent
- * @param ?array $meta
- */
- public function __construct(
- public readonly string $toolUseId,
- array $content,
- public readonly ?array $structuredContent = null,
- public readonly bool $isError = false,
- public readonly ?array $meta = null,
- ) {
- foreach ($content as $item) {
- if (!$item instanceof TextContent && !$item instanceof ImageContent && !$item instanceof AudioContent && !$item instanceof ResourceLink && !$item instanceof EmbeddedResource) {
- throw new InvalidArgumentException('Tool result content must contain standard content blocks.');
- }
- }
-
- // array_filter() and friends preserve keys, and a keyed array serializes
- // as a JSON object rather than the array the schema requires.
- $this->content = array_values($content);
-
- parent::__construct('tool_result');
- }
-
- /**
- * @param array $data
- */
- public static function fromArray(array $data): self
- {
- if (!isset($data['toolUseId']) || !\is_string($data['toolUseId'])) {
- throw new InvalidArgumentException('Missing or invalid "toolUseId" in ToolResultContent data.');
- }
- if (!isset($data['content']) || !\is_array($data['content'])) {
- throw new InvalidArgumentException('Missing or invalid "content" in ToolResultContent data.');
- }
-
- $content = [];
- foreach ($data['content'] as $item) {
- if (!\is_array($item)) {
- throw new InvalidArgumentException('Invalid content block in ToolResultContent data.');
- }
-
- $content[] = match ($item['type'] ?? null) {
- 'text' => TextContent::fromArray($item),
- 'image' => ImageContent::fromArray($item),
- 'audio' => AudioContent::fromArray($item),
- 'resource_link' => ResourceLink::fromArray($item),
- 'resource' => EmbeddedResource::fromArray($item),
- default => throw new InvalidArgumentException(\sprintf('Unsupported tool result content type "%s".', $item['type'] ?? null)),
- };
- }
-
- return new self(
- $data['toolUseId'],
- $content,
- isset($data['structuredContent']) && \is_array($data['structuredContent']) ? $data['structuredContent'] : null,
- isset($data['isError']) && true === $data['isError'],
- isset($data['_meta']) && \is_array($data['_meta']) ? $data['_meta'] : null,
- );
- }
-
- /**
- * @return array
- */
- public function jsonSerialize(): array
- {
- $data = [
- 'type' => $this->type,
- 'toolUseId' => $this->toolUseId,
- 'content' => $this->content,
- ];
-
- // Optional in the schema with a default of false, so only sent when it is true.
- if ($this->isError) {
- $data['isError'] = true;
- }
-
- if (null !== $this->structuredContent) {
- $data['structuredContent'] = $this->structuredContent;
- }
- if (null !== $this->meta) {
- $data['_meta'] = $this->meta;
- }
-
- return $data;
- }
-}
diff --git a/src/Schema/Content/ToolUseContent.php b/src/Schema/Content/ToolUseContent.php
deleted file mode 100644
index 6acdc439..00000000
--- a/src/Schema/Content/ToolUseContent.php
+++ /dev/null
@@ -1,75 +0,0 @@
- $input
- * @param ?array $meta
- */
- public function __construct(
- public readonly string $id,
- public readonly string $name,
- public readonly array $input,
- public readonly ?array $meta = null,
- ) {
- parent::__construct('tool_use');
- }
-
- /**
- * @param array{id?: mixed, name?: mixed, input?: mixed, _meta?: mixed} $data
- */
- public static function fromArray(array $data): self
- {
- if (!isset($data['id']) || !\is_string($data['id'])) {
- throw new InvalidArgumentException('Missing or invalid "id" in ToolUseContent data.');
- }
- if (!isset($data['name']) || !\is_string($data['name'])) {
- throw new InvalidArgumentException('Missing or invalid "name" in ToolUseContent data.');
- }
- if (!isset($data['input']) || !\is_array($data['input'])) {
- throw new InvalidArgumentException('Missing or invalid "input" in ToolUseContent data.');
- }
-
- return new self(
- $data['id'],
- $data['name'],
- $data['input'],
- isset($data['_meta']) && \is_array($data['_meta']) ? $data['_meta'] : null,
- );
- }
-
- /**
- * @return array{type: 'tool_use', id: string, name: string, input: array|\stdClass, _meta?: array}
- */
- public function jsonSerialize(): array
- {
- $data = [
- 'type' => $this->type,
- 'id' => $this->id,
- 'name' => $this->name,
- 'input' => $this->input ?: new \stdClass(),
- ];
-
- if (null !== $this->meta) {
- $data['_meta'] = $this->meta;
- }
-
- return $data;
- }
-}
diff --git a/src/Schema/Elicitation/AbstractSchemaDefinition.php b/src/Schema/Elicitation/AbstractSchemaDefinition.php
deleted file mode 100644
index 63fdc219..00000000
--- a/src/Schema/Elicitation/AbstractSchemaDefinition.php
+++ /dev/null
@@ -1,66 +0,0 @@
-
- */
-abstract class AbstractSchemaDefinition implements \JsonSerializable
-{
- public function __construct(
- public readonly string $title,
- public readonly ?string $description = null,
- ) {
- }
-
- /**
- * Validate that title exists and is a string in the data array.
- *
- * @param array $data
- *
- * @throws InvalidArgumentException
- */
- protected static function validateTitle(array $data, string $schemaType): void
- {
- if (!isset($data['title']) || !\is_string($data['title'])) {
- throw new InvalidArgumentException(\sprintf('Missing or invalid "title" for %s schema definition.', $schemaType));
- }
- }
-
- /**
- * Build the base JSON structure with type, title, and optional description.
- *
- * @return array
- */
- protected function buildBaseJson(string $type): array
- {
- $data = [
- 'type' => $type,
- 'title' => $this->title,
- ];
-
- if (null !== $this->description) {
- $data['description'] = $this->description;
- }
-
- return $data;
- }
-
- /**
- * @return array
- */
- abstract public function jsonSerialize(): array;
-}
diff --git a/src/Schema/Elicitation/BooleanSchemaDefinition.php b/src/Schema/Elicitation/BooleanSchemaDefinition.php
deleted file mode 100644
index 9cfaa8a3..00000000
--- a/src/Schema/Elicitation/BooleanSchemaDefinition.php
+++ /dev/null
@@ -1,70 +0,0 @@
-
- */
-final class BooleanSchemaDefinition extends AbstractSchemaDefinition
-{
- /**
- * @param string $title Human-readable title for the field
- * @param string|null $description Optional description/help text
- * @param bool|null $default Optional default value
- */
- public function __construct(
- string $title,
- ?string $description = null,
- public readonly ?bool $default = null,
- ) {
- parent::__construct($title, $description);
- }
-
- /**
- * @param array{
- * title: string,
- * description?: string,
- * default?: bool,
- * } $data
- */
- public static function fromArray(array $data): self
- {
- self::validateTitle($data, 'boolean');
-
- return new self(
- title: $data['title'],
- description: $data['description'] ?? null,
- default: isset($data['default']) ? (bool) $data['default'] : null,
- );
- }
-
- /**
- * @return array{
- * type: string,
- * title: string,
- * description?: string,
- * default?: bool,
- * }
- */
- public function jsonSerialize(): array
- {
- $data = $this->buildBaseJson('boolean');
-
- if (null !== $this->default) {
- $data['default'] = $this->default;
- }
-
- return $data;
- }
-}
diff --git a/src/Schema/Elicitation/ElicitationSchema.php b/src/Schema/Elicitation/ElicitationSchema.php
deleted file mode 100644
index 36d130af..00000000
--- a/src/Schema/Elicitation/ElicitationSchema.php
+++ /dev/null
@@ -1,160 +0,0 @@
-
- */
-final class ElicitationSchema implements \JsonSerializable
-{
- /**
- * @param array $properties Property definitions keyed by name
- * @param string[] $required Array of required property names
- */
- public function __construct(
- public readonly array $properties,
- public readonly array $required = [],
- ) {
- if ([] === $properties) {
- throw new InvalidArgumentException('properties array must not be empty.');
- }
-
- foreach ($required as $name) {
- if (!\is_string($name)) {
- throw new InvalidArgumentException('Each entry in "required" must be a string.');
- }
- if (!\array_key_exists($name, $properties)) {
- throw new InvalidArgumentException(\sprintf('Required property "%s" is not defined in properties.', $name));
- }
- }
- }
-
- /**
- * Create an ElicitationSchema from array data.
- *
- * @param array{
- * type?: string,
- * properties: array,
- * required?: string[],
- * } $data
- */
- public static function fromArray(array $data): self
- {
- if (isset($data['type']) && 'object' !== $data['type']) {
- throw new InvalidArgumentException('ElicitationSchema type must be "object".');
- }
-
- if (!isset($data['properties']) || !\is_array($data['properties'])) {
- throw new InvalidArgumentException('Missing or invalid "properties" for elicitation schema.');
- }
-
- $properties = [];
- foreach ($data['properties'] as $name => $propertyData) {
- if (!\is_array($propertyData)) {
- throw new InvalidArgumentException(\sprintf('Property "%s" must be an array.', $name));
- }
- $properties[$name] = self::createSchemaDefinition($propertyData);
- }
-
- if (isset($data['required']) && !\is_array($data['required'])) {
- throw new InvalidArgumentException('Invalid "required" for elicitation schema; expected an array.');
- }
-
- return new self(
- properties: $properties,
- required: $data['required'] ?? [],
- );
- }
-
- /**
- * Create a schema definition from array data.
- *
- * @param array $data
- */
- private static function createSchemaDefinition(array $data): AbstractSchemaDefinition
- {
- if (!isset($data['type']) || !\is_string($data['type'])) {
- throw new InvalidArgumentException('Missing or invalid "type" for schema definition.');
- }
-
- return match ($data['type']) {
- 'string' => self::resolveStringType($data),
- 'integer', 'number' => NumberSchemaDefinition::fromArray($data),
- 'boolean' => BooleanSchemaDefinition::fromArray($data),
- 'array' => self::resolveArrayType($data),
- default => throw new InvalidArgumentException(\sprintf('Unsupported type "%s". Supported types are: string, integer, number, boolean, array.', $data['type'])),
- };
- }
-
- /**
- * @param array $data
- */
- private static function resolveStringType(array $data): AbstractSchemaDefinition
- {
- if (isset($data['oneOf'])) {
- return TitledEnumSchemaDefinition::fromArray($data);
- }
-
- if (isset($data['enum'])) {
- return EnumSchemaDefinition::fromArray($data);
- }
-
- return StringSchemaDefinition::fromArray($data);
- }
-
- /**
- * @param array $data
- */
- private static function resolveArrayType(array $data): AbstractSchemaDefinition
- {
- if (isset($data['items']['anyOf'])) {
- return TitledMultiSelectEnumSchemaDefinition::fromArray($data);
- }
-
- if (isset($data['items']['enum'])) {
- return MultiSelectEnumSchemaDefinition::fromArray($data);
- }
-
- throw new InvalidArgumentException('Array type must have "items" with either "enum" or "anyOf".');
- }
-
- /**
- * @return array{
- * type: string,
- * properties: array,
- * required?: string[],
- * }
- */
- public function jsonSerialize(): array
- {
- $data = [
- 'type' => 'object',
- 'properties' => [],
- ];
-
- foreach ($this->properties as $name => $property) {
- $data['properties'][$name] = $property->jsonSerialize();
- }
-
- if ([] !== $this->required) {
- $data['required'] = $this->required;
- }
-
- return $data;
- }
-}
diff --git a/src/Schema/Elicitation/EnumSchemaDefinition.php b/src/Schema/Elicitation/EnumSchemaDefinition.php
deleted file mode 100644
index 003bf354..00000000
--- a/src/Schema/Elicitation/EnumSchemaDefinition.php
+++ /dev/null
@@ -1,118 +0,0 @@
-
- */
-final class EnumSchemaDefinition extends AbstractSchemaDefinition
-{
- /**
- * @param string $title Human-readable title for the field
- * @param string[] $enum Array of allowed string values
- * @param string|null $description Optional description/help text
- * @param string|null $default Optional default value (must be in enum)
- * @param string[]|null $enumNames Optional human-readable labels for each enum value
- */
- public function __construct(
- string $title,
- public readonly array $enum,
- ?string $description = null,
- public readonly ?string $default = null,
- public readonly ?array $enumNames = null,
- ) {
- parent::__construct($title, $description);
-
- if ([] === $enum) {
- throw new InvalidArgumentException('enum array must not be empty.');
- }
-
- foreach ($enum as $value) {
- if (!\is_string($value)) {
- throw new InvalidArgumentException('All enum values must be strings.');
- }
- }
-
- if (null !== $enumNames && \count($enumNames) !== \count($enum)) {
- throw new InvalidArgumentException('enumNames length must match enum length.');
- }
-
- if (null !== $default && !\in_array($default, $enum, true)) {
- throw new InvalidArgumentException(\sprintf('Default value "%s" is not in the enum array.', $default));
- }
- }
-
- /**
- * @param array{
- * title: string,
- * enum: string[],
- * description?: string,
- * default?: string,
- * enumNames?: string[],
- * } $data
- */
- public static function fromArray(array $data): self
- {
- self::validateTitle($data, 'enum');
-
- if (!isset($data['enum']) || !\is_array($data['enum'])) {
- throw new InvalidArgumentException('Missing or invalid "enum" for enum schema definition.');
- }
-
- return new self(
- title: $data['title'],
- enum: $data['enum'],
- description: $data['description'] ?? null,
- default: $data['default'] ?? null,
- enumNames: $data['enumNames'] ?? null,
- );
- }
-
- /**
- * @return array{
- * type: string,
- * title: string,
- * enum: string[],
- * description?: string,
- * default?: string,
- * enumNames?: string[],
- * }
- */
- public function jsonSerialize(): array
- {
- $data = [
- 'type' => 'string',
- 'title' => $this->title,
- 'enum' => $this->enum,
- ];
-
- if (null !== $this->description) {
- $data['description'] = $this->description;
- }
-
- if (null !== $this->default) {
- $data['default'] = $this->default;
- }
-
- if (null !== $this->enumNames) {
- $data['enumNames'] = $this->enumNames;
- }
-
- return $data;
- }
-}
diff --git a/src/Schema/Elicitation/MultiSelectEnumSchemaDefinition.php b/src/Schema/Elicitation/MultiSelectEnumSchemaDefinition.php
deleted file mode 100644
index 28046bcf..00000000
--- a/src/Schema/Elicitation/MultiSelectEnumSchemaDefinition.php
+++ /dev/null
@@ -1,127 +0,0 @@
- $maxItems) {
- throw new InvalidArgumentException('minItems cannot be greater than maxItems.');
- }
-
- if (null !== $default) {
- foreach ($default as $value) {
- if (!\in_array($value, $enum, true)) {
- throw new InvalidArgumentException(\sprintf('Default value "%s" is not in the enum array.', $value));
- }
- }
- }
- }
-
- /**
- * @param array{
- * title: string,
- * items: array{type: string, enum: string[]},
- * description?: string,
- * default?: string[],
- * minItems?: int,
- * maxItems?: int,
- * } $data
- */
- public static function fromArray(array $data): self
- {
- self::validateTitle($data, 'multi-select enum');
-
- if (!isset($data['items']['enum']) || !\is_array($data['items']['enum'])) {
- throw new InvalidArgumentException('Missing or invalid "items.enum" for multi-select enum schema definition.');
- }
-
- return new self(
- title: $data['title'],
- enum: $data['items']['enum'],
- description: $data['description'] ?? null,
- default: $data['default'] ?? null,
- minItems: isset($data['minItems']) ? (int) $data['minItems'] : null,
- maxItems: isset($data['maxItems']) ? (int) $data['maxItems'] : null,
- );
- }
-
- /**
- * @return array
- */
- public function jsonSerialize(): array
- {
- $data = $this->buildBaseJson('array');
- $data['items'] = [
- 'type' => 'string',
- 'enum' => $this->enum,
- ];
-
- if (null !== $this->default) {
- $data['default'] = $this->default;
- }
-
- if (null !== $this->minItems) {
- $data['minItems'] = $this->minItems;
- }
-
- if (null !== $this->maxItems) {
- $data['maxItems'] = $this->maxItems;
- }
-
- return $data;
- }
-}
diff --git a/src/Schema/Elicitation/NumberSchemaDefinition.php b/src/Schema/Elicitation/NumberSchemaDefinition.php
deleted file mode 100644
index 0ad32771..00000000
--- a/src/Schema/Elicitation/NumberSchemaDefinition.php
+++ /dev/null
@@ -1,115 +0,0 @@
-
- */
-final class NumberSchemaDefinition extends AbstractSchemaDefinition
-{
- /**
- * @param string $title Human-readable title for the field
- * @param bool $integerOnly Whether to restrict to integer values only
- * @param string|null $description Optional description/help text
- * @param int|float|null $default Optional default value
- * @param int|float|null $minimum Optional minimum value (inclusive)
- * @param int|float|null $maximum Optional maximum value (inclusive)
- */
- public function __construct(
- string $title,
- public readonly bool $integerOnly = false,
- ?string $description = null,
- public readonly int|float|null $default = null,
- public readonly int|float|null $minimum = null,
- public readonly int|float|null $maximum = null,
- ) {
- parent::__construct($title, $description);
-
- if (null !== $minimum && null !== $maximum && $minimum > $maximum) {
- throw new InvalidArgumentException('minimum cannot be greater than maximum.');
- }
-
- if (null !== $default && null !== $minimum && $default < $minimum) {
- throw new InvalidArgumentException('default value cannot be less than minimum.');
- }
-
- if (null !== $default && null !== $maximum && $default > $maximum) {
- throw new InvalidArgumentException('default value cannot be greater than maximum.');
- }
-
- if ($integerOnly && null !== $default && $default !== (int) $default) {
- throw new InvalidArgumentException('default value must be an integer when integerOnly is true.');
- }
- }
-
- /**
- * @param array{
- * type: string,
- * title: string,
- * description?: string,
- * default?: int|float,
- * minimum?: int|float,
- * maximum?: int|float,
- * } $data
- */
- public static function fromArray(array $data): self
- {
- self::validateTitle($data, 'number');
-
- $type = $data['type'] ?? 'number';
- $integerOnly = 'integer' === $type;
-
- return new self(
- title: $data['title'],
- integerOnly: $integerOnly,
- description: $data['description'] ?? null,
- default: $data['default'] ?? null,
- minimum: $data['minimum'] ?? null,
- maximum: $data['maximum'] ?? null,
- );
- }
-
- /**
- * @return array{
- * type: string,
- * title: string,
- * description?: string,
- * default?: int|float,
- * minimum?: int|float,
- * maximum?: int|float,
- * }
- */
- public function jsonSerialize(): array
- {
- $data = $this->buildBaseJson($this->integerOnly ? 'integer' : 'number');
-
- if (null !== $this->default) {
- $data['default'] = $this->default;
- }
-
- if (null !== $this->minimum) {
- $data['minimum'] = $this->minimum;
- }
-
- if (null !== $this->maximum) {
- $data['maximum'] = $this->maximum;
- }
-
- return $data;
- }
-}
diff --git a/src/Schema/Elicitation/StringSchemaDefinition.php b/src/Schema/Elicitation/StringSchemaDefinition.php
deleted file mode 100644
index 76319261..00000000
--- a/src/Schema/Elicitation/StringSchemaDefinition.php
+++ /dev/null
@@ -1,119 +0,0 @@
-
- */
-final class StringSchemaDefinition extends AbstractSchemaDefinition
-{
- private const VALID_FORMATS = ['date', 'date-time', 'email', 'uri'];
-
- /**
- * @param string $title Human-readable title for the field
- * @param string|null $description Optional description/help text
- * @param string|null $default Optional default value
- * @param string|null $format Optional format constraint (date, date-time, email, uri)
- * @param int|null $minLength Optional minimum string length
- * @param int|null $maxLength Optional maximum string length
- */
- public function __construct(
- string $title,
- ?string $description = null,
- public readonly ?string $default = null,
- public readonly ?string $format = null,
- public readonly ?int $minLength = null,
- public readonly ?int $maxLength = null,
- ) {
- parent::__construct($title, $description);
-
- if (null !== $format && !\in_array($format, self::VALID_FORMATS, true)) {
- throw new InvalidArgumentException(\sprintf('Invalid format "%s". Valid formats are: %s.', $format, implode(', ', self::VALID_FORMATS)));
- }
-
- if (null !== $minLength && $minLength < 0) {
- throw new InvalidArgumentException('minLength must be non-negative.');
- }
-
- if (null !== $maxLength && $maxLength < 0) {
- throw new InvalidArgumentException('maxLength must be non-negative.');
- }
-
- if (null !== $minLength && null !== $maxLength && $minLength > $maxLength) {
- throw new InvalidArgumentException('minLength cannot be greater than maxLength.');
- }
- }
-
- /**
- * @param array{
- * title: string,
- * description?: string,
- * default?: string,
- * format?: string,
- * minLength?: int,
- * maxLength?: int,
- * } $data
- */
- public static function fromArray(array $data): self
- {
- self::validateTitle($data, 'string');
-
- return new self(
- title: $data['title'],
- description: $data['description'] ?? null,
- default: $data['default'] ?? null,
- format: $data['format'] ?? null,
- minLength: isset($data['minLength']) ? (int) $data['minLength'] : null,
- maxLength: isset($data['maxLength']) ? (int) $data['maxLength'] : null,
- );
- }
-
- /**
- * @return array{
- * type: string,
- * title: string,
- * description?: string,
- * default?: string,
- * format?: string,
- * minLength?: int,
- * maxLength?: int,
- * }
- */
- public function jsonSerialize(): array
- {
- $data = $this->buildBaseJson('string');
-
- if (null !== $this->default) {
- $data['default'] = $this->default;
- }
-
- if (null !== $this->format) {
- $data['format'] = $this->format;
- }
-
- if (null !== $this->minLength) {
- $data['minLength'] = $this->minLength;
- }
-
- if (null !== $this->maxLength) {
- $data['maxLength'] = $this->maxLength;
- }
-
- return $data;
- }
-}
diff --git a/src/Schema/Elicitation/TitledEnumSchemaDefinition.php b/src/Schema/Elicitation/TitledEnumSchemaDefinition.php
deleted file mode 100644
index 54eb568b..00000000
--- a/src/Schema/Elicitation/TitledEnumSchemaDefinition.php
+++ /dev/null
@@ -1,98 +0,0 @@
- $oneOf Array of const/title pairs
- * @param string|null $description Optional description/help text
- * @param string|null $default Optional default value (must match a const)
- */
- public function __construct(
- string $title,
- public readonly array $oneOf,
- ?string $description = null,
- public readonly ?string $default = null,
- ) {
- parent::__construct($title, $description);
-
- if ([] === $oneOf) {
- throw new InvalidArgumentException('oneOf array must not be empty.');
- }
-
- $consts = [];
- foreach ($oneOf as $item) {
- if (!isset($item['const']) || !\is_string($item['const'])) {
- throw new InvalidArgumentException('Each oneOf item must have a string "const" property.');
- }
- if (!isset($item['title']) || !\is_string($item['title'])) {
- throw new InvalidArgumentException('Each oneOf item must have a string "title" property.');
- }
- $consts[] = $item['const'];
- }
-
- if (null !== $default && !\in_array($default, $consts, true)) {
- throw new InvalidArgumentException(\sprintf('Default value "%s" is not in the oneOf const values.', $default));
- }
- }
-
- /**
- * @param array{
- * title: string,
- * oneOf: list,
- * description?: string,
- * default?: string,
- * } $data
- */
- public static function fromArray(array $data): self
- {
- self::validateTitle($data, 'titled enum');
-
- if (!isset($data['oneOf']) || !\is_array($data['oneOf'])) {
- throw new InvalidArgumentException('Missing or invalid "oneOf" for titled enum schema definition.');
- }
-
- return new self(
- title: $data['title'],
- oneOf: $data['oneOf'],
- description: $data['description'] ?? null,
- default: $data['default'] ?? null,
- );
- }
-
- /**
- * @return array
- */
- public function jsonSerialize(): array
- {
- $data = $this->buildBaseJson('string');
- $data['oneOf'] = $this->oneOf;
-
- if (null !== $this->default) {
- $data['default'] = $this->default;
- }
-
- return $data;
- }
-}
diff --git a/src/Schema/Elicitation/TitledMultiSelectEnumSchemaDefinition.php b/src/Schema/Elicitation/TitledMultiSelectEnumSchemaDefinition.php
deleted file mode 100644
index baab9b0b..00000000
--- a/src/Schema/Elicitation/TitledMultiSelectEnumSchemaDefinition.php
+++ /dev/null
@@ -1,131 +0,0 @@
- $anyOf Array of const/title pairs
- * @param string|null $description Optional description/help text
- * @param string[]|null $default Optional default selected values (must be subset of anyOf consts)
- * @param int|null $minItems Optional minimum number of selections
- * @param int|null $maxItems Optional maximum number of selections
- */
- public function __construct(
- string $title,
- public readonly array $anyOf,
- ?string $description = null,
- public readonly ?array $default = null,
- public readonly ?int $minItems = null,
- public readonly ?int $maxItems = null,
- ) {
- parent::__construct($title, $description);
-
- if ([] === $anyOf) {
- throw new InvalidArgumentException('anyOf array must not be empty.');
- }
-
- $consts = [];
- foreach ($anyOf as $item) {
- if (!isset($item['const']) || !\is_string($item['const'])) {
- throw new InvalidArgumentException('Each anyOf item must have a string "const" property.');
- }
- if (!isset($item['title']) || !\is_string($item['title'])) {
- throw new InvalidArgumentException('Each anyOf item must have a string "title" property.');
- }
- $consts[] = $item['const'];
- }
-
- if (null !== $minItems && $minItems < 0) {
- throw new InvalidArgumentException('minItems must be non-negative.');
- }
-
- if (null !== $maxItems && $maxItems < 0) {
- throw new InvalidArgumentException('maxItems must be non-negative.');
- }
-
- if (null !== $minItems && null !== $maxItems && $minItems > $maxItems) {
- throw new InvalidArgumentException('minItems cannot be greater than maxItems.');
- }
-
- if (null !== $default) {
- foreach ($default as $value) {
- if (!\in_array($value, $consts, true)) {
- throw new InvalidArgumentException(\sprintf('Default value "%s" is not in the anyOf const values.', $value));
- }
- }
- }
- }
-
- /**
- * @param array{
- * title: string,
- * items: array{anyOf: list},
- * description?: string,
- * default?: string[],
- * minItems?: int,
- * maxItems?: int,
- * } $data
- */
- public static function fromArray(array $data): self
- {
- self::validateTitle($data, 'titled multi-select enum');
-
- if (!isset($data['items']['anyOf']) || !\is_array($data['items']['anyOf'])) {
- throw new InvalidArgumentException('Missing or invalid "items.anyOf" for titled multi-select enum schema definition.');
- }
-
- return new self(
- title: $data['title'],
- anyOf: $data['items']['anyOf'],
- description: $data['description'] ?? null,
- default: $data['default'] ?? null,
- minItems: isset($data['minItems']) ? (int) $data['minItems'] : null,
- maxItems: isset($data['maxItems']) ? (int) $data['maxItems'] : null,
- );
- }
-
- /**
- * @return array
- */
- public function jsonSerialize(): array
- {
- $data = $this->buildBaseJson('array');
- $data['items'] = [
- 'anyOf' => $this->anyOf,
- ];
-
- if (null !== $this->default) {
- $data['default'] = $this->default;
- }
-
- if (null !== $this->minItems) {
- $data['minItems'] = $this->minItems;
- }
-
- if (null !== $this->maxItems) {
- $data['maxItems'] = $this->maxItems;
- }
-
- return $data;
- }
-}
diff --git a/src/Schema/Enum/ElicitAction.php b/src/Schema/Enum/ElicitAction.php
deleted file mode 100644
index 373e6189..00000000
--- a/src/Schema/Enum/ElicitAction.php
+++ /dev/null
@@ -1,24 +0,0 @@
-
- */
-enum ElicitAction: string
-{
- case Accept = 'accept';
- case Decline = 'decline';
- case Cancel = 'cancel';
-}
diff --git a/src/Schema/Enum/LoggingLevel.php b/src/Schema/Enum/LoggingLevel.php
deleted file mode 100644
index e9aecef8..00000000
--- a/src/Schema/Enum/LoggingLevel.php
+++ /dev/null
@@ -1,32 +0,0 @@
-
- */
-enum LoggingLevel: string
-{
- case Debug = 'debug';
- case Info = 'info';
- case Notice = 'notice';
- case Warning = 'warning';
- case Error = 'error';
- case Critical = 'critical';
- case Alert = 'alert';
- case Emergency = 'emergency';
-}
diff --git a/src/Schema/Enum/ProtocolVersion.php b/src/Schema/Enum/ProtocolVersion.php
deleted file mode 100644
index b62396bb..00000000
--- a/src/Schema/Enum/ProtocolVersion.php
+++ /dev/null
@@ -1,139 +0,0 @@
-
- */
-enum ProtocolVersion: string
-{
- // Declaration order is also the era boundary: everything declared from
- // FIRST_MODERN_VERSION onwards is modern. A new handshake-era revision has
- // to be inserted above that case — appending it here would silently drop it
- // out of the handshake negotiation.
- case V2024_11_05 = '2024-11-05';
- case V2025_03_26 = '2025-03-26';
- case V2025_06_18 = '2025-06-18';
- case V2025_11_25 = '2025-11-25';
- case V2026_07_28 = '2026-07-28';
-
- /**
- * First revision of the modern era, in which the `initialize` handshake was
- * replaced by per-request metadata.
- */
- public const FIRST_MODERN_VERSION = self::V2026_07_28;
-
- /**
- * Version a server assumes when a client omits the `MCP-Protocol-Version`
- * header on the Streamable HTTP transport.
- *
- * This is the revision that introduced both Streamable HTTP and the header
- * itself, so a request without the header cannot be newer than this.
- */
- public const DEFAULT_HEADER_VERSION = self::V2025_03_26;
-
- /**
- * Newest revision reachable through the `initialize` handshake.
- *
- * This is what a server counter-offers when it cannot honour the version a
- * client asked for, and what a handshake-era client offers by default.
- */
- public static function latestHandshake(): self
- {
- $versions = self::handshakeVersions();
-
- return $versions[\count($versions) - 1];
- }
-
- /**
- * Revisions reachable through the `initialize` handshake, oldest to newest.
- *
- * @return non-empty-list
- */
- public static function handshakeVersions(): array
- {
- return array_values(array_filter(self::cases(), static fn (self $v): bool => !$v->isModern()));
- }
-
- /**
- * Revisions using the per-request metadata envelope, oldest to newest.
- *
- * @return non-empty-list
- */
- public static function modernVersions(): array
- {
- return array_values(array_filter(self::cases(), static fn (self $v): bool => $v->isModern()));
- }
-
- /**
- * Whether this revision belongs to the modern, per-request-metadata era.
- */
- public function isModern(): bool
- {
- return $this->isAtLeast(self::FIRST_MODERN_VERSION);
- }
-
- /**
- * Whether this revision restricts `structuredContent` to a JSON object.
- *
- * SEP-2106, part of {@see self::V2026_07_28}, widened `outputSchema` to any
- * JSON Schema 2020-12 and `structuredContent` to any JSON value conforming to
- * it. Up to `2025-11-25` both are restricted to an object.
- *
- * @see https://modelcontextprotocol.io/specification/2026-07-28/server/tools#structured-content
- */
- public function requiresObjectStructuredContent(): bool
- {
- return !$this->isAtLeast(self::V2026_07_28);
- }
-
- /**
- * Whether this revision is at least as new as $minimum.
- */
- public function isAtLeast(self $minimum): bool
- {
- return $this->position() >= $minimum->position();
- }
-
- /**
- * Index of this revision in the chronological declaration order.
- */
- private function position(): int
- {
- foreach (self::cases() as $index => $case) {
- if ($case === $this) {
- return $index;
- }
- }
-
- throw new LogicException(\sprintf('Protocol version "%s" is not a declared case.', $this->value));
- }
-}
diff --git a/src/Schema/Enum/Role.php b/src/Schema/Enum/Role.php
deleted file mode 100644
index 0b1d97a5..00000000
--- a/src/Schema/Enum/Role.php
+++ /dev/null
@@ -1,23 +0,0 @@
-
- */
-enum Role: string
-{
- case User = 'user';
- case Assistant = 'assistant';
-}
diff --git a/src/Schema/Enum/SamplingContext.php b/src/Schema/Enum/SamplingContext.php
deleted file mode 100644
index 4c1f1851..00000000
--- a/src/Schema/Enum/SamplingContext.php
+++ /dev/null
@@ -1,19 +0,0 @@
-
- */
-final class McpApps implements ServerExtensionInterface
-{
- public const EXTENSION_ID = 'io.modelcontextprotocol/ui';
- public const MIME_TYPE = 'text/html;profile=mcp-app';
- public const URI_SCHEME = 'ui';
-
- public function getId(): string
- {
- return self::EXTENSION_ID;
- }
-
- /**
- * @return array{mimeTypes: string[]}
- */
- public function getCapabilities(): array
- {
- return ['mimeTypes' => [self::MIME_TYPE]];
- }
-
- /**
- * The marker value for the `_meta.ui` field on a UI resource *descriptor*
- * (its `resources/list` entry), flagging the resource as an MCP App.
- *
- * The structured CSP/permissions metadata instead belongs on the resource
- * *content* (the `resources/read` payload) via {@see UiResourceContentMeta}.
- */
- public static function resourceMarker(): \stdClass
- {
- return new \stdClass();
- }
-}
diff --git a/src/Schema/Extension/Apps/ToolVisibility.php b/src/Schema/Extension/Apps/ToolVisibility.php
deleted file mode 100644
index bdb12ef1..00000000
--- a/src/Schema/Extension/Apps/ToolVisibility.php
+++ /dev/null
@@ -1,26 +0,0 @@
-
- */
-enum ToolVisibility: string
-{
- /** Visible to and callable by the LLM agent. */
- case Model = 'model';
-
- /** Callable by the MCP App (HTML view) only, hidden from the model's tools/list. */
- case App = 'app';
-}
diff --git a/src/Schema/Extension/Apps/UiResourceContentMeta.php b/src/Schema/Extension/Apps/UiResourceContentMeta.php
deleted file mode 100644
index 666a7e99..00000000
--- a/src/Schema/Extension/Apps/UiResourceContentMeta.php
+++ /dev/null
@@ -1,96 +0,0 @@
-
- */
-final class UiResourceContentMeta implements \JsonSerializable
-{
- public function __construct(
- public readonly ?UiResourceCsp $csp = null,
- public readonly ?UiResourcePermissions $permissions = null,
- public readonly ?string $domain = null,
- public readonly ?bool $prefersBorder = null,
- ) {
- }
-
- /**
- * @param UiResourceContentMetaData $data
- */
- public static function fromArray(array $data): self
- {
- if (isset($data['csp']) && !\is_array($data['csp'])) {
- throw new InvalidArgumentException('Invalid "csp" in UiResourceContentMeta data; expected an array.');
- }
- if (isset($data['permissions']) && !\is_array($data['permissions'])) {
- throw new InvalidArgumentException('Invalid "permissions" in UiResourceContentMeta data; expected an array.');
- }
- if (isset($data['domain']) && !\is_string($data['domain'])) {
- throw new InvalidArgumentException('Invalid "domain" in UiResourceContentMeta data.');
- }
- if (isset($data['prefersBorder']) && !\is_bool($data['prefersBorder'])) {
- throw new InvalidArgumentException('Invalid "prefersBorder" in UiResourceContentMeta data.');
- }
-
- return new self(
- csp: isset($data['csp']) ? UiResourceCsp::fromArray($data['csp']) : null,
- permissions: isset($data['permissions']) ? UiResourcePermissions::fromArray($data['permissions']) : null,
- domain: $data['domain'] ?? null,
- prefersBorder: $data['prefersBorder'] ?? null,
- );
- }
-
- /**
- * @return array{
- * csp?: UiResourceCsp,
- * permissions?: UiResourcePermissions,
- * domain?: string,
- * prefersBorder?: bool
- * }
- */
- public function jsonSerialize(): array
- {
- $data = [];
-
- if (null !== $this->csp) {
- $data['csp'] = $this->csp;
- }
- if (null !== $this->permissions) {
- $data['permissions'] = $this->permissions;
- }
- if (null !== $this->domain) {
- $data['domain'] = $this->domain;
- }
- if (null !== $this->prefersBorder) {
- $data['prefersBorder'] = $this->prefersBorder;
- }
-
- return $data;
- }
-}
diff --git a/src/Schema/Extension/Apps/UiResourceCsp.php b/src/Schema/Extension/Apps/UiResourceCsp.php
deleted file mode 100644
index e3550a67..00000000
--- a/src/Schema/Extension/Apps/UiResourceCsp.php
+++ /dev/null
@@ -1,90 +0,0 @@
-
- */
-final class UiResourceCsp implements \JsonSerializable
-{
- /**
- * @param ?string[] $connectDomains domains allowed for network requests (fetch, XHR, WebSocket)
- * @param ?string[] $resourceDomains domains allowed for static resources (images, scripts, styles)
- * @param ?string[] $frameDomains domains allowed for nested iframes
- * @param ?string[] $baseUriDomains domains allowed for base URI origins
- */
- public function __construct(
- public readonly ?array $connectDomains = null,
- public readonly ?array $resourceDomains = null,
- public readonly ?array $frameDomains = null,
- public readonly ?array $baseUriDomains = null,
- ) {
- }
-
- /**
- * @param UiResourceCspData $data
- */
- public static function fromArray(array $data): self
- {
- foreach (['connectDomains', 'resourceDomains', 'frameDomains', 'baseUriDomains'] as $key) {
- if (isset($data[$key]) && !\is_array($data[$key])) {
- throw new InvalidArgumentException(\sprintf('Invalid "%s" in UiResourceCsp data; expected an array.', $key));
- }
- }
-
- return new self(
- connectDomains: $data['connectDomains'] ?? null,
- resourceDomains: $data['resourceDomains'] ?? null,
- frameDomains: $data['frameDomains'] ?? null,
- baseUriDomains: $data['baseUriDomains'] ?? null,
- );
- }
-
- /**
- * @return UiResourceCspData
- */
- public function jsonSerialize(): array
- {
- $data = [];
-
- // The MCP Apps spec (2026-01-26) defines "empty or omitted" identically
- // for every CSP allow-list, so empty arrays are dropped, not emitted as `[]`.
- if ($this->connectDomains) {
- $data['connectDomains'] = $this->connectDomains;
- }
- if ($this->resourceDomains) {
- $data['resourceDomains'] = $this->resourceDomains;
- }
- if ($this->frameDomains) {
- $data['frameDomains'] = $this->frameDomains;
- }
- if ($this->baseUriDomains) {
- $data['baseUriDomains'] = $this->baseUriDomains;
- }
-
- return $data;
- }
-}
diff --git a/src/Schema/Extension/Apps/UiResourcePermissions.php b/src/Schema/Extension/Apps/UiResourcePermissions.php
deleted file mode 100644
index bed14ba0..00000000
--- a/src/Schema/Extension/Apps/UiResourcePermissions.php
+++ /dev/null
@@ -1,76 +0,0 @@
-,
- * microphone?: \stdClass|array,
- * geolocation?: \stdClass|array,
- * clipboardWrite?: \stdClass|array
- * }
- *
- * @author Christopher Hertel
- */
-final class UiResourcePermissions implements \JsonSerializable
-{
- public function __construct(
- public readonly bool $camera = false,
- public readonly bool $microphone = false,
- public readonly bool $geolocation = false,
- public readonly bool $clipboardWrite = false,
- ) {
- }
-
- /**
- * @param UiResourcePermissionsData $data
- */
- public static function fromArray(array $data): self
- {
- // A permission is requested when its key is present with the spec's `{}`
- // marker; isset() accepts that (array/object forms) and rejects a stray null.
- return new self(
- camera: isset($data['camera']),
- microphone: isset($data['microphone']),
- geolocation: isset($data['geolocation']),
- clipboardWrite: isset($data['clipboardWrite']),
- );
- }
-
- /**
- * @return array