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 @@ -

- - - - - - - {{ project.name }} - -

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 - -
- -[![Latest Version](https://img.shields.io/packagist/v/mcp/sdk.svg)](https://packagist.org/packages/mcp/sdk) -[![CI](https://github.com/modelcontextprotocol/php-sdk/actions/workflows/pipeline.yaml/badge.svg)](https://github.com/modelcontextprotocol/php-sdk/actions/workflows/pipeline.yaml) -[![PHP Version](https://img.shields.io/packagist/php-v/mcp/sdk.svg)](https://packagist.org/packages/mcp/sdk) -[![License](https://img.shields.io/packagist/l/mcp/sdk.svg)](LICENSE) -[![Server Conformance](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/modelcontextprotocol/php-sdk/badges/server-conformance.json)](https://github.com/modelcontextprotocol/php-sdk/actions/workflows/conformance-weekly.yaml) -[![Client Conformance](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/modelcontextprotocol/php-sdk/badges/client-conformance.json)](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 - - - -
- - -
-
-
-
-
-
-
-
🌤️
-
-
-
- Humidity — -
-
-
- - - 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 - */ - public function jsonSerialize(): array - { - $data = []; - - if ($this->camera) { - $data['camera'] = new \stdClass(); - } - if ($this->microphone) { - $data['microphone'] = new \stdClass(); - } - if ($this->geolocation) { - $data['geolocation'] = new \stdClass(); - } - if ($this->clipboardWrite) { - $data['clipboardWrite'] = new \stdClass(); - } - - return $data; - } -} diff --git a/src/Schema/Extension/Apps/UiToolMeta.php b/src/Schema/Extension/Apps/UiToolMeta.php deleted file mode 100644 index 235df2a4..00000000 --- a/src/Schema/Extension/Apps/UiToolMeta.php +++ /dev/null @@ -1,82 +0,0 @@ - - */ -final class UiToolMeta implements \JsonSerializable -{ - /** - * @param ?string $resourceUri the ui:// URI of the linked UI resource - * @param ?list $visibility who can see/call this tool; when omitted the host - * defaults to both {@see ToolVisibility::Model} and {@see ToolVisibility::App} - */ - public function __construct( - public readonly ?string $resourceUri = null, - public readonly ?array $visibility = null, - ) { - } - - /** - * @param UiToolMetaData $data - */ - public static function fromArray(array $data): self - { - if (isset($data['resourceUri']) && !\is_string($data['resourceUri'])) { - throw new InvalidArgumentException('Invalid "resourceUri" in UiToolMeta data.'); - } - if (isset($data['visibility']) && !\is_array($data['visibility'])) { - throw new InvalidArgumentException('Invalid "visibility" in UiToolMeta data; expected an array.'); - } - - return new self( - resourceUri: $data['resourceUri'] ?? null, - visibility: isset($data['visibility']) ? array_map( - static function (mixed $entry): ToolVisibility { - if (!\is_string($entry) || null === $case = ToolVisibility::tryFrom($entry)) { - throw new InvalidArgumentException('Each entry in "visibility" of UiToolMeta data must be a valid tool visibility.'); - } - - return $case; - }, - $data['visibility'], - ) : null, - ); - } - - /** - * @return UiToolMetaData - */ - public function jsonSerialize(): array - { - $data = []; - - if (null !== $this->resourceUri) { - $data['resourceUri'] = $this->resourceUri; - } - if (null !== $this->visibility) { - $data['visibility'] = array_map(static fn (ToolVisibility $v): string => $v->value, $this->visibility); - } - - return $data; - } -} diff --git a/src/Schema/Extension/ServerExtensionInterface.php b/src/Schema/Extension/ServerExtensionInterface.php deleted file mode 100644 index 8793a966..00000000 --- a/src/Schema/Extension/ServerExtensionInterface.php +++ /dev/null @@ -1,40 +0,0 @@ -]` in the initialize - * response. - * - * @author Christopher Hertel - */ -interface ServerExtensionInterface -{ - /** - * The reverse-DNS identifier used as the key under `capabilities.extensions`. - */ - public function getId(): string; - - /** - * The capability payload announced for this extension. - * - * The returned array is cast to an object and embedded under - * `capabilities.extensions[]` in the initialize response, so every value - * must be JSON-serializable (scalars, arrays, or `JsonSerializable` objects). - * - * @return array - */ - public function getCapabilities(): array; -} diff --git a/src/Schema/Icon.php b/src/Schema/Icon.php deleted file mode 100644 index 13929e03..00000000 --- a/src/Schema/Icon.php +++ /dev/null @@ -1,119 +0,0 @@ - - */ -class Icon implements \JsonSerializable -{ - /** - * @param string $src a standard URI pointing to an icon resource - * @param ?string $mimeType optional override if the server's MIME type is missing or generic - * @param ?string[] $sizes optional array of strings that specify sizes at which the icon can be used. - * Each string should be in WxH format (e.g., `"48x48"`, `"96x96"`) or `"any"` for - * scalable formats like SVG. - */ - public function __construct( - public readonly string $src, - public readonly ?string $mimeType = null, - public readonly ?array $sizes = null, - ) { - if (empty($src)) { - throw new InvalidArgumentException('Icon "src" must be a non-empty string.'); - } - if (!preg_match('#^(https?://|data:)#', $src)) { - throw new InvalidArgumentException('Icon "src" must be a valid URL or data URI.'); - } - - if (null !== $sizes) { - foreach ($sizes as $size) { - if (!\is_string($size)) { - throw new InvalidArgumentException('Each size in "sizes" must be a string.'); - } - if (!preg_match('/^(any|\d+x\d+)$/', $size)) { - throw new InvalidArgumentException(\sprintf('Invalid size format "%s" in "sizes". Expected "WxH" or "any".', $size)); - } - } - } - } - - /** - * @param IconData $data - */ - public static function fromArray(array $data): self - { - if (empty($data['src']) || !\is_string($data['src'])) { - throw new InvalidArgumentException('Invalid or missing "src" in Icon data.'); - } - if (isset($data['mimeType']) && !\is_string($data['mimeType'])) { - throw new InvalidArgumentException('Invalid "mimeType" in Icon data.'); - } - if (isset($data['sizes']) && !\is_array($data['sizes'])) { - throw new InvalidArgumentException('Invalid "sizes" in Icon data.'); - } - - return new self($data['src'], $data['mimeType'] ?? null, $data['sizes'] ?? null); - } - - /** - * Hydrates an "icons" list, rejecting entries that are not objects. - * - * @param array $icons - * @param string $context the surrounding schema type, used for the error message - * - * @return self[] - */ - public static function listFromArray(array $icons, string $context): array - { - return array_map( - static function (mixed $icon) use ($context): self { - if (!\is_array($icon)) { - throw new InvalidArgumentException(\sprintf('Each entry in "icons" of %s data must be an array.', $context)); - } - - return self::fromArray($icon); - }, - $icons, - ); - } - - /** - * @return IconData - */ - public function jsonSerialize(): array - { - $data = [ - 'src' => $this->src, - ]; - - if (null !== $this->mimeType) { - $data['mimeType'] = $this->mimeType; - } - - if (null !== $this->sizes) { - $data['sizes'] = $this->sizes; - } - - return $data; - } -} diff --git a/src/Schema/Implementation.php b/src/Schema/Implementation.php deleted file mode 100644 index 214a38f8..00000000 --- a/src/Schema/Implementation.php +++ /dev/null @@ -1,109 +0,0 @@ - - */ -class Implementation implements \JsonSerializable -{ - /** - * @param ?Icon[] $icons - */ - public function __construct( - public readonly string $name = 'app', - public readonly string $version = 'dev', - public readonly ?string $description = null, - public readonly ?array $icons = null, - public readonly ?string $websiteUrl = null, - ) { - } - - /** - * @param array{ - * name: string, - * version: string, - * description?: string, - * icons?: IconData[], - * websiteUrl?: string, - * } $data - */ - public static function fromArray(array $data): self - { - if (!isset($data['name']) || !\is_string($data['name']) || '' === $data['name']) { - throw new InvalidArgumentException('Invalid or missing "name" in Implementation data.'); - } - if (!isset($data['version']) || !\is_string($data['version']) || '' === $data['version']) { - throw new InvalidArgumentException('Invalid or missing "version" in Implementation data.'); - } - - if (isset($data['icons'])) { - if (!\is_array($data['icons'])) { - throw new InvalidArgumentException('Invalid "icons" in Implementation data; expected an array.'); - } - - $data['icons'] = Icon::listFromArray($data['icons'], 'Implementation'); - } - - if (isset($data['description']) && !\is_string($data['description'])) { - throw new InvalidArgumentException('Invalid "description" in Implementation data.'); - } - if (isset($data['websiteUrl']) && !\is_string($data['websiteUrl'])) { - throw new InvalidArgumentException('Invalid "websiteUrl" in Implementation data.'); - } - - return new self( - $data['name'], - $data['version'], - $data['description'] ?? null, - $data['icons'] ?? null, - $data['websiteUrl'] ?? null, - ); - } - - /** - * @return array{ - * name: string, - * version: string, - * description?: string, - * icons?: Icon[], - * websiteUrl?: string, - * } - */ - public function jsonSerialize(): array - { - $data = [ - 'name' => $this->name, - 'version' => $this->version, - ]; - - if (null !== $this->description) { - $data['description'] = $this->description; - } - - if (null !== $this->icons) { - $data['icons'] = $this->icons; - } - - if (null !== $this->websiteUrl) { - $data['websiteUrl'] = $this->websiteUrl; - } - - return $data; - } -} diff --git a/src/Schema/JsonRpc/Error.php b/src/Schema/JsonRpc/Error.php deleted file mode 100644 index 532e406d..00000000 --- a/src/Schema/JsonRpc/Error.php +++ /dev/null @@ -1,147 +0,0 @@ - - */ -class Error implements MessageInterface -{ - public const PARSE_ERROR = -32700; - public const INVALID_REQUEST = -32600; - public const METHOD_NOT_FOUND = -32601; - public const INVALID_PARAMS = -32602; - public const INTERNAL_ERROR = -32603; - public const SERVER_ERROR = -32000; - public const RESOURCE_NOT_FOUND = -32002; - - /** - * @param int $code the error type that occurred - * @param string $message a short description of the error - * @param mixed|null $data additional information about the error - */ - public function __construct( - public readonly string|int $id, - public readonly int $code, - public readonly string $message, - public readonly mixed $data = null, - ) { - } - - /** - * @param ErrorData $data - */ - final public static function fromArray(array $data): self - { - if (!isset($data['jsonrpc']) || MessageInterface::JSONRPC_VERSION !== $data['jsonrpc']) { - throw new InvalidArgumentException('Invalid or missing "jsonrpc" in Error data.'); - } - if (!isset($data['id'])) { - throw new InvalidArgumentException('Invalid or missing "id" in Error data.'); - } - if (!\is_string($data['id']) && !\is_int($data['id'])) { - throw new InvalidArgumentException('Invalid "id" type in Error data.'); - } - if (!isset($data['error']) || !\is_array($data['error'])) { - throw new InvalidArgumentException('Invalid or missing "error" field in Error data.'); - } - if (!isset($data['error']['code']) || !\is_int($data['error']['code'])) { - throw new InvalidArgumentException('Invalid or missing "code" in Error data.'); - } - if (!isset($data['error']['message']) || !\is_string($data['error']['message'])) { - throw new InvalidArgumentException('Invalid or missing "message" in Error data.'); - } - - return new self($data['id'], $data['error']['code'], $data['error']['message'], $data['error']['data'] ?? null); - } - - final public static function forParseError(string $message, string|int $id = ''): self - { - return new self($id, self::PARSE_ERROR, $message); - } - - final public static function forInvalidRequest(string $message, string|int $id = ''): self - { - return new self($id, self::INVALID_REQUEST, $message); - } - - final public static function forMethodNotFound(string $message, string|int $id = ''): self - { - return new self($id, self::METHOD_NOT_FOUND, $message); - } - - final public static function forInvalidParams(string $message, string|int $id = '', mixed $data = null): self - { - return new self($id, self::INVALID_PARAMS, $message, $data); - } - - final public static function forInternalError(string $message, string|int $id = ''): self - { - return new self($id, self::INTERNAL_ERROR, $message); - } - - final public static function forServerError(string $message, string|int $id = ''): self - { - return new self($id, self::SERVER_ERROR, $message); - } - - final public static function forResourceNotFound(string $message, string|int $id = ''): self - { - return new self($id, self::RESOURCE_NOT_FOUND, $message); - } - - public function getId(): string|int - { - return $this->id; - } - - /** - * @return array{ - * jsonrpc: string, - * id: string|int, - * error: array{ - * code: int, - * message: string, - * }, - * data?: mixed, - * } - */ - public function jsonSerialize(): array - { - $error = [ - 'code' => $this->code, - 'message' => $this->message, - ]; - - if (null !== $this->data) { - $error['data'] = $this->data; - } - - return [ - 'jsonrpc' => MessageInterface::JSONRPC_VERSION, - 'id' => $this->id, - 'error' => $error, - ]; - } -} diff --git a/src/Schema/JsonRpc/HasMethodInterface.php b/src/Schema/JsonRpc/HasMethodInterface.php deleted file mode 100644 index cbaca8e0..00000000 --- a/src/Schema/JsonRpc/HasMethodInterface.php +++ /dev/null @@ -1,27 +0,0 @@ - - */ -interface HasMethodInterface -{ - public static function getMethod(): string; - - /** - * @param array $data - */ - public static function fromArray(array $data): self; -} diff --git a/src/Schema/JsonRpc/MessageInterface.php b/src/Schema/JsonRpc/MessageInterface.php deleted file mode 100644 index 9427eaac..00000000 --- a/src/Schema/JsonRpc/MessageInterface.php +++ /dev/null @@ -1,25 +0,0 @@ - - */ -interface MessageInterface extends \JsonSerializable -{ - public const JSONRPC_VERSION = '2.0'; - public const PROTOCOL_VERSION = ProtocolVersion::V2025_11_25; -} diff --git a/src/Schema/JsonRpc/Notification.php b/src/Schema/JsonRpc/Notification.php deleted file mode 100644 index bfd37f1f..00000000 --- a/src/Schema/JsonRpc/Notification.php +++ /dev/null @@ -1,88 +0,0 @@ -|null - * } - * - * @author Kyrian Obikwelu - */ -abstract class Notification implements HasMethodInterface, MessageInterface -{ - /** - * @var array|null - */ - protected ?array $meta = null; - - abstract public static function getMethod(): string; - - /** - * @param NotificationData $data - */ - public static function fromArray(array $data): self - { - if (isset($data['id'])) { - throw new InvalidArgumentException('Notification MUST NOT contain an "id" field.'); - } - if (!isset($data['method']) || !\is_string($data['method'])) { - throw new InvalidArgumentException('Invalid or missing "method" for Notification.'); - } - $params = $data['params'] ?? null; - if (null !== $params && !\is_array($params)) { - throw new InvalidArgumentException('"params" for Notification must be an array/object or null.'); - } - - $notification = static::fromParams($params); - - if (isset($data['params']['_meta'])) { - $notification->meta = $data['params']['_meta']; - } - - return $notification; - } - - /** - * @param array|null $params - */ - abstract protected static function fromParams(?array $params): self; - - /** - * @return NotificationData - */ - public function jsonSerialize(): array - { - $array = [ - 'jsonrpc' => MessageInterface::JSONRPC_VERSION, - 'method' => static::getMethod(), - ]; - if (null !== $params = $this->getParams()) { - $array['params'] = $params; - } - - if (null !== $this->meta && !isset($params['meta'])) { - $array['params']['_meta'] = $this->meta; - } - - return $array; - } - - /** - * @return array|null - */ - abstract protected function getParams(): ?array; -} diff --git a/src/Schema/JsonRpc/Request.php b/src/Schema/JsonRpc/Request.php deleted file mode 100644 index cb8ed836..00000000 --- a/src/Schema/JsonRpc/Request.php +++ /dev/null @@ -1,136 +0,0 @@ -, - * } - * - * @author Kyrian Obikwelu - */ -abstract class Request implements HasMethodInterface, MessageInterface -{ - protected string|int $id; - /** - * @var array|null - */ - protected ?array $meta = null; - - abstract public static function getMethod(): string; - - /** - * @param RequestData $data - */ - public static function fromArray(array $data): static - { - if (($data['jsonrpc'] ?? null) !== MessageInterface::JSONRPC_VERSION) { - throw new InvalidArgumentException('Invalid or missing "jsonrpc" version for Request.'); - } - if (!isset($data['id']) || !\is_string($data['id']) && !\is_int($data['id'])) { - throw new InvalidArgumentException('Invalid or missing "id" for Request.'); - } - if (!isset($data['method']) || !\is_string($data['method'])) { - throw new InvalidArgumentException('Invalid or missing "method" for Request.'); - } - $params = $data['params'] ?? null; - if ($params instanceof \stdClass) { - $params = (array) $params; - } - if (null !== $params && !\is_array($params)) { - throw new InvalidArgumentException('"params" for Request must be an array/object or null.'); - } - - $request = static::fromParams($params); - $request->id = $data['id']; - - if (isset($data['params']['_meta'])) { - $meta = $data['params']['_meta']; - if ($meta instanceof \stdClass) { - $meta = (array) $meta; - } - if (\is_array($meta)) { - $request->meta = $meta; - } - } - - return $request; - } - - /** - * @param array|null $params - */ - abstract protected static function fromParams(?array $params): static; - - public function getId(): string|int - { - return $this->id; - } - - /** - * @return array|null - */ - public function getMeta(): ?array - { - return $this->meta; - } - - public function withId(string|int $id): static - { - $clone = clone $this; - $clone->id = $id; - - return $clone; - } - - /** - * @param array|null $meta - */ - public function withMeta(?array $meta): static - { - $clone = clone $this; - $clone->meta = $meta; - - return $clone; - } - - /** - * @return RequestData - */ - public function jsonSerialize(): array - { - $array = [ - 'jsonrpc' => MessageInterface::JSONRPC_VERSION, - 'id' => $this->id, - 'method' => static::getMethod(), - ]; - if (null !== $params = $this->getParams()) { - $array['params'] = $params; - } - - if (null !== $this->meta && !isset($params['meta'])) { - $array['params']['_meta'] = $this->meta; - } - - return $array; - } - - /** - * @return array|null - */ - abstract protected function getParams(): ?array; -} diff --git a/src/Schema/JsonRpc/Response.php b/src/Schema/JsonRpc/Response.php deleted file mode 100644 index 7f2d82ba..00000000 --- a/src/Schema/JsonRpc/Response.php +++ /dev/null @@ -1,86 +0,0 @@ -, - * } - * - * @author Kyrian Obikwelu - */ -class Response implements MessageInterface -{ - /** - * @param string|int $id this MUST be the same as the value of the id member in the Request Object - * @param TResult $result the value of this member is determined by the method invoked on the Server - */ - public function __construct( - public readonly string|int $id, - /** @var TResult */ - public readonly mixed $result, - ) { - } - - public function getId(): string|int - { - return $this->id; - } - - /** - * @param ResponseData $data - * - * @return self> - */ - public static function fromArray(array $data): self - { - if (($data['jsonrpc'] ?? null) !== MessageInterface::JSONRPC_VERSION) { - throw new InvalidArgumentException('Invalid or missing "jsonrpc" version for Response.'); - } - if (!isset($data['id'])) { - throw new InvalidArgumentException('Missing "id" for Response.'); - } - if (!\is_string($data['id']) && !\is_int($data['id'])) { - throw new InvalidArgumentException('Invalid "id" type for Response.'); - } - if (!isset($data['result'])) { - throw new InvalidArgumentException('Response must contain "result" field.'); - } - if (!\is_array($data['result'])) { - throw new InvalidArgumentException('Response "result" must be an array.'); - } - - return new self($data['id'], $data['result']); - } - - /** - * @return array{ - * jsonrpc: string, - * id: string|int, - * result: mixed, - * } - */ - public function jsonSerialize(): array - { - return [ - 'jsonrpc' => MessageInterface::JSONRPC_VERSION, - 'id' => $this->id, - 'result' => $this->result, - ]; - } -} diff --git a/src/Schema/JsonRpc/ResultInterface.php b/src/Schema/JsonRpc/ResultInterface.php deleted file mode 100644 index 180dfba7..00000000 --- a/src/Schema/JsonRpc/ResultInterface.php +++ /dev/null @@ -1,21 +0,0 @@ - - */ -interface ResultInterface extends \JsonSerializable -{ -} diff --git a/src/Schema/ModelHint.php b/src/Schema/ModelHint.php deleted file mode 100644 index 0ad04ccd..00000000 --- a/src/Schema/ModelHint.php +++ /dev/null @@ -1,50 +0,0 @@ - - */ -class ModelHint implements \JsonSerializable -{ - /** - * @param string|null $name A hint for a model name. - * - * The client SHOULD treat this as a substring of a model name; for example: - * - `claude-3-5-sonnet` should match `claude-3-5-sonnet-20241022` - * - `sonnet` should match `claude-3-5-sonnet-20241022`, `claude-3-sonnet-20240229`, etc. - * - `claude` should match any Claude model - * - * The client MAY also map the string to a different provider's model name or a different model family, as long as it fills a similar niche; for example: - * - `gemini-1.5-flash` could match `claude-3-haiku-20240307` - */ - public function __construct( - public readonly ?string $name = null, - ) { - } - - /** - * @return array{name: string}|array{} - */ - public function jsonSerialize(): array - { - if (null === $this->name) { - return []; - } - - return ['name' => $this->name]; - } -} diff --git a/src/Schema/ModelPreferences.php b/src/Schema/ModelPreferences.php deleted file mode 100644 index 88cf5584..00000000 --- a/src/Schema/ModelPreferences.php +++ /dev/null @@ -1,116 +0,0 @@ - - */ -class ModelPreferences implements \JsonSerializable -{ - /** - * @param ModelHint[]|null $hints Optional hints about the model to use. - * - * If multiple hints are specified, the client MUST evaluate them in order (such that the first match is taken). - * - * The client SHOULD prioritize these hints over the numeric priorities, but MAY still use the priorities to select from ambiguous matches. - * @param float|null $costPriority How much to prioritize cost when selecting a model. A value of 0 means cost is not important, while - * a value of 1 means cost is the most important factor. Minimum value is 0, maximum value is 1. - * @param float|null $speedPriority How much to prioritize sampling speed (latency) when selecting a model. A value of 0 means - * speed is not important, while a value of 1 means speed is the most important factor. Minimum value is 0, maximum value is 1. - * @param float|null $intelligencePriority How much to prioritize intelligence and capabilities when selecting a model. A value of 0 - * means intelligence is not important, while a value of 1 means intelligence is the most important factor. - */ - public function __construct( - public readonly ?array $hints = null, - public readonly ?float $costPriority = null, - public readonly ?float $speedPriority = null, - public readonly ?float $intelligencePriority = null, - ) { - } - - /** - * @param ModelPreferencesData $preferences - */ - public static function fromArray(array $preferences): self - { - if (isset($preferences['hints']) && !\is_array($preferences['hints'])) { - throw new InvalidArgumentException('Invalid "hints" in ModelPreferences data.'); - } - - return new self( - $preferences['hints'] ?? null, - self::priority($preferences, 'costPriority'), - self::priority($preferences, 'speedPriority'), - self::priority($preferences, 'intelligencePriority'), - ); - } - - /** - * @param array $preferences - */ - private static function priority(array $preferences, string $key): ?float - { - if (!isset($preferences[$key])) { - return null; - } - - // JSON numbers decode to int when they have no fractional part. - if (!\is_float($preferences[$key]) && !\is_int($preferences[$key])) { - throw new InvalidArgumentException(\sprintf('Invalid "%s" in ModelPreferences data; expected a number.', $key)); - } - - return (float) $preferences[$key]; - } - - /** - * @return ModelPreferencesData - */ - public function jsonSerialize(): array - { - $result = []; - if (null !== $this->hints) { - $result['hints'] = $this->hints; - } - if (null !== $this->costPriority) { - $result['costPriority'] = $this->costPriority; - } - if (null !== $this->speedPriority) { - $result['speedPriority'] = $this->speedPriority; - } - if (null !== $this->intelligencePriority) { - $result['intelligencePriority'] = $this->intelligencePriority; - } - - return $result; - } -} diff --git a/src/Schema/Notification/CancelledNotification.php b/src/Schema/Notification/CancelledNotification.php deleted file mode 100644 index 4ae32576..00000000 --- a/src/Schema/Notification/CancelledNotification.php +++ /dev/null @@ -1,68 +0,0 @@ - - */ -class CancelledNotification extends Notification -{ - /** - * @param string|int $requestId The ID of the request that is being cancelled. This MUST correspond to the ID of a request previously issued in the same direction. - * @param ?string $reason An optional string describing the reason for the cancellation. This MAY be logged or presented to the user. - */ - public function __construct( - public readonly string|int $requestId, - public readonly ?string $reason = null, - ) { - } - - public static function getMethod(): string - { - return 'notifications/cancelled'; - } - - protected static function fromParams(?array $params): Notification - { - if (null === $params || !isset($params['requestId']) || (!\is_string($params['requestId']) && !\is_int($params['requestId']))) { - throw new InvalidArgumentException('Invalid or missing "requestId" parameter for "notifications/cancelled" notification.'); - } - - if (isset($params['reason']) && !\is_string($params['reason'])) { - throw new InvalidArgumentException('Invalid "reason" parameter for "notifications/cancelled" notification.'); - } - - return new self($params['requestId'], $params['reason'] ?? null); - } - - protected function getParams(): ?array - { - $params = ['requestId' => $this->requestId]; - - if (null !== $this->reason) { - $params['reason'] = $this->reason; - } - - return $params; - } -} diff --git a/src/Schema/Notification/InitializedNotification.php b/src/Schema/Notification/InitializedNotification.php deleted file mode 100644 index 8e733275..00000000 --- a/src/Schema/Notification/InitializedNotification.php +++ /dev/null @@ -1,37 +0,0 @@ - - */ -class InitializedNotification extends Notification -{ - public static function getMethod(): string - { - return 'notifications/initialized'; - } - - public static function fromParams(?array $params): self - { - return new self(); - } - - protected function getParams(): ?array - { - return null; - } -} diff --git a/src/Schema/Notification/LoggingMessageNotification.php b/src/Schema/Notification/LoggingMessageNotification.php deleted file mode 100644 index 3088f491..00000000 --- a/src/Schema/Notification/LoggingMessageNotification.php +++ /dev/null @@ -1,76 +0,0 @@ - - */ -class LoggingMessageNotification extends Notification -{ - /** - * @param LoggingLevel $level the severity of this log message - * @param mixed $data The data to be logged, such as a string message or an object. Any JSON serializable type is allowed here. - * @param ?string $logger an optional name of the logger issuing this message - */ - public function __construct( - public readonly LoggingLevel $level, - public readonly mixed $data, - public readonly ?string $logger = null, - ) { - } - - public static function getMethod(): string - { - return 'notifications/message'; - } - - protected static function fromParams(?array $params): Notification - { - if (!isset($params['level']) || !\is_string($params['level'])) { - throw new InvalidArgumentException('Missing or invalid "level" parameter for "notifications/message" notification.'); - } - - if (!isset($params['data'])) { - throw new InvalidArgumentException('Missing "data" parameter for "notifications/message" notification.'); - } - - if (null === $level = LoggingLevel::tryFrom($params['level'])) { - throw new InvalidArgumentException(\sprintf('Invalid "level" parameter "%s" for "notifications/message" notification.', $params['level'])); - } - - if (isset($params['logger']) && !\is_string($params['logger'])) { - throw new InvalidArgumentException('Invalid "logger" parameter for "notifications/message" notification.'); - } - - $data = \is_string($params['data']) ? $params['data'] : json_encode($params['data']); - - return new self($level, $data, $params['logger'] ?? null); - } - - protected function getParams(): ?array - { - $params = [ - 'level' => $this->level->value, - 'data' => $this->data, - ]; - - if (null !== $this->logger) { - $params['logger'] = $this->logger; - } - - return $params; - } -} diff --git a/src/Schema/Notification/ProgressNotification.php b/src/Schema/Notification/ProgressNotification.php deleted file mode 100644 index 15fc50ac..00000000 --- a/src/Schema/Notification/ProgressNotification.php +++ /dev/null @@ -1,89 +0,0 @@ - - */ -class ProgressNotification extends Notification -{ - /** - * @param string|int $progressToken the progress token which was given in the initial request, used to - * associate this notification with the request that is proceeding - * @param float $progress The progress thus far. This should increase every time progress is - * made, even if the total is unknown. - * @param ?float $total total number of items to process (or total progress required), if known - * @param ?string $message an optional message describing the current progress - */ - public function __construct( - public readonly string|int $progressToken, - public readonly float $progress, - public readonly ?float $total = null, - public readonly ?string $message = null, - ) { - } - - public static function getMethod(): string - { - return 'notifications/progress'; - } - - protected static function fromParams(?array $params): Notification - { - // JSON numbers decode to int when they have no fractional part. - if (!isset($params['progressToken']) || !\is_string($params['progressToken']) && !\is_int($params['progressToken'])) { - throw new InvalidArgumentException('Missing or invalid "progressToken" parameter for "notifications/progress" notification.'); - } - - if (!isset($params['progress']) || !\is_float($params['progress']) && !\is_int($params['progress'])) { - throw new InvalidArgumentException('Missing or invalid "progress" parameter for "notifications/progress" notification.'); - } - - if (isset($params['total']) && !\is_float($params['total']) && !\is_int($params['total'])) { - throw new InvalidArgumentException('Invalid "total" parameter for "notifications/progress" notification.'); - } - - if (isset($params['message']) && !\is_string($params['message'])) { - throw new InvalidArgumentException('Invalid "message" parameter for "notifications/progress" notification.'); - } - - return new self( - $params['progressToken'], - (float) $params['progress'], - isset($params['total']) ? (float) $params['total'] : null, - $params['message'] ?? null, - ); - } - - protected function getParams(): ?array - { - $params = [ - 'progressToken' => $this->progressToken, - 'progress' => $this->progress, - ]; - - if (null !== $this->total) { - $params['total'] = $this->total; - } - - if (null !== $this->message) { - $params['message'] = $this->message; - } - - return $params; - } -} diff --git a/src/Schema/Notification/PromptListChangedNotification.php b/src/Schema/Notification/PromptListChangedNotification.php deleted file mode 100644 index 1557e1e3..00000000 --- a/src/Schema/Notification/PromptListChangedNotification.php +++ /dev/null @@ -1,37 +0,0 @@ - - */ -class PromptListChangedNotification extends Notification -{ - public static function getMethod(): string - { - return 'notifications/prompts/list_changed'; - } - - protected static function fromParams(?array $params): Notification - { - return new self(); - } - - protected function getParams(): ?array - { - return null; - } -} diff --git a/src/Schema/Notification/ResourceListChangedNotification.php b/src/Schema/Notification/ResourceListChangedNotification.php deleted file mode 100644 index 9a15e12c..00000000 --- a/src/Schema/Notification/ResourceListChangedNotification.php +++ /dev/null @@ -1,37 +0,0 @@ - - */ -class ResourceListChangedNotification extends Notification -{ - public static function getMethod(): string - { - return 'notifications/resources/list_changed'; - } - - protected static function fromParams(?array $params): Notification - { - return new self(); - } - - protected function getParams(): ?array - { - return null; - } -} diff --git a/src/Schema/Notification/ResourceUpdatedNotification.php b/src/Schema/Notification/ResourceUpdatedNotification.php deleted file mode 100644 index 946d500e..00000000 --- a/src/Schema/Notification/ResourceUpdatedNotification.php +++ /dev/null @@ -1,47 +0,0 @@ - - */ -class ResourceUpdatedNotification extends Notification -{ - public function __construct( - public readonly string $uri, - ) { - } - - public static function getMethod(): string - { - return 'notifications/resources/updated'; - } - - protected static function fromParams(?array $params): Notification - { - if (null === $params || !isset($params['uri']) || !\is_string($params['uri'])) { - throw new InvalidArgumentException('Invalid or missing "uri" parameter for notifications/resources/updated notification.'); - } - - return new self($params['uri']); - } - - protected function getParams(): ?array - { - return [ - 'uri' => $this->uri, - ]; - } -} diff --git a/src/Schema/Notification/RootsListChangedNotification.php b/src/Schema/Notification/RootsListChangedNotification.php deleted file mode 100644 index 3c95829a..00000000 --- a/src/Schema/Notification/RootsListChangedNotification.php +++ /dev/null @@ -1,39 +0,0 @@ - - */ -class RootsListChangedNotification extends Notification -{ - public static function getMethod(): string - { - return 'notifications/roots/list_changed'; - } - - protected static function fromParams(?array $params): Notification - { - return new self(); - } - - protected function getParams(): ?array - { - return null; - } -} diff --git a/src/Schema/Notification/ToolListChangedNotification.php b/src/Schema/Notification/ToolListChangedNotification.php deleted file mode 100644 index e081df29..00000000 --- a/src/Schema/Notification/ToolListChangedNotification.php +++ /dev/null @@ -1,37 +0,0 @@ - - */ -class ToolListChangedNotification extends Notification -{ - public static function getMethod(): string - { - return 'notifications/tools/list_changed'; - } - - protected static function fromParams(?array $params): Notification - { - return new self(); - } - - protected function getParams(): ?array - { - return null; - } -} diff --git a/src/Schema/Page.php b/src/Schema/Page.php deleted file mode 100644 index 3d546464..00000000 --- a/src/Schema/Page.php +++ /dev/null @@ -1,35 +0,0 @@ - - */ -final class Page extends \ArrayObject -{ - /** - * @param array $references Items can be Tool, Prompt, ResourceTemplate, or ResourceDefinition - */ - public function __construct( - public readonly array $references, - public readonly ?string $nextCursor, - ) { - parent::__construct($references, \ArrayObject::ARRAY_AS_PROPS); - } - - public function count(): int - { - return \count($this->references); - } -} diff --git a/src/Schema/Prompt.php b/src/Schema/Prompt.php deleted file mode 100644 index 1f33e410..00000000 --- a/src/Schema/Prompt.php +++ /dev/null @@ -1,133 +0,0 @@ - - * } - * - * @author Kyrian Obikwelu - */ -class Prompt implements \JsonSerializable -{ - /** - * @param string $name the name of the prompt or prompt template - * @param ?string $title Optional human-readable title for display in UI - * @param ?string $description an optional description of what this prompt provides - * @param ?PromptArgument[] $arguments A list of arguments for templating. Null if not a template. - * @param ?Icon[] $icons optional icons representing the prompt - * @param ?array $meta Optional metadata - */ - public function __construct( - public readonly string $name, - public readonly ?string $title = null, - public readonly ?string $description = null, - public readonly ?array $arguments = null, - public readonly ?array $icons = null, - public readonly ?array $meta = null, - ) { - if (null !== $this->arguments) { - foreach ($this->arguments as $arg) { - if (!$arg instanceof PromptArgument) { - throw new InvalidArgumentException('All items in Prompt "arguments" must be PromptArgument instances.'); - } - } - } - } - - /** - * @param PromptData $data - */ - public static function fromArray(array $data): self - { - if (empty($data['name']) || !\is_string($data['name'])) { - throw new InvalidArgumentException('Invalid or missing "name" in Prompt data.'); - } - $arguments = null; - if (isset($data['arguments']) && \is_array($data['arguments'])) { - $arguments = array_map( - static function (mixed $argData): PromptArgument { - if (!\is_array($argData)) { - throw new InvalidArgumentException('Each entry in "arguments" of Prompt data must be an array.'); - } - - return PromptArgument::fromArray($argData); - }, - $data['arguments'], - ); - } - - if (isset($data['_meta']) && !\is_array($data['_meta'])) { - throw new InvalidArgumentException('Invalid "_meta" in Prompt data.'); - } - if (isset($data['title']) && !\is_string($data['title'])) { - throw new InvalidArgumentException('Invalid "title" in Prompt data.'); - } - if (isset($data['description']) && !\is_string($data['description'])) { - throw new InvalidArgumentException('Invalid "description" in Prompt data.'); - } - - return new self( - name: $data['name'], - title: $data['title'] ?? null, - description: $data['description'] ?? null, - arguments: $arguments, - icons: isset($data['icons']) && \is_array($data['icons']) ? Icon::listFromArray($data['icons'], 'Prompt') : null, - meta: isset($data['_meta']) ? $data['_meta'] : null - ); - } - - /** - * @return array{ - * name: string, - * title?: string, - * description?: string, - * arguments?: array, - * icons?: Icon[], - * _meta?: array - * } - */ - public function jsonSerialize(): array - { - $data = ['name' => $this->name]; - if (null !== $this->title) { - $data['title'] = $this->title; - } - if (null !== $this->description) { - $data['description'] = $this->description; - } - if (null !== $this->arguments) { - $data['arguments'] = $this->arguments; - } - if (null !== $this->icons) { - $data['icons'] = $this->icons; - } - if (null !== $this->meta) { - $data['_meta'] = $this->meta; - } - - return $data; - } -} diff --git a/src/Schema/PromptArgument.php b/src/Schema/PromptArgument.php deleted file mode 100644 index 38fe3dc0..00000000 --- a/src/Schema/PromptArgument.php +++ /dev/null @@ -1,79 +0,0 @@ - - */ -class PromptArgument implements \JsonSerializable -{ - /** - * @param string $name the name of the argument - * @param string|null $description a human-readable description of the argument - * @param bool|null $required Whether this argument must be provided. Defaults to false per MCP spec if omitted. - */ - public function __construct( - public readonly string $name, - public readonly ?string $description = null, - public readonly ?bool $required = null, - ) { - } - - /** - * @param PromptArgumentData $data - */ - public static function fromArray(array $data): self - { - if (empty($data['name']) || !\is_string($data['name'])) { - throw new InvalidArgumentException('Invalid or missing "name" in PromptArgument data.'); - } - - if (isset($data['description']) && !\is_string($data['description'])) { - throw new InvalidArgumentException('Invalid "description" in PromptArgument data.'); - } - if (isset($data['required']) && !\is_bool($data['required'])) { - throw new InvalidArgumentException('Invalid "required" in PromptArgument data.'); - } - - return new self( - name: $data['name'], - description: $data['description'] ?? null, - required: $data['required'] ?? null // Keep null if not present, MCP implies default false - ); - } - - /** - * @return PromptArgumentData - */ - public function jsonSerialize(): array - { - $data = ['name' => $this->name]; - if (null !== $this->description) { - $data['description'] = $this->description; - } - if (null !== $this->required) { - $data['required'] = $this->required; - } - - return $data; - } -} diff --git a/src/Schema/PromptReference.php b/src/Schema/PromptReference.php deleted file mode 100644 index 10ec755e..00000000 --- a/src/Schema/PromptReference.php +++ /dev/null @@ -1,44 +0,0 @@ - - */ -class PromptReference implements \JsonSerializable -{ - public string $type = 'ref/prompt'; - - /** - * @param string $name The name of the prompt or prompt template - */ - public function __construct( - public readonly string $name, - ) { - } - - /** - * @return array{ - * type: string, - * name: string, - * } - */ - public function jsonSerialize(): array - { - return [ - 'type' => $this->type, - 'name' => $this->name, - ]; - } -} diff --git a/src/Schema/Request/CallToolRequest.php b/src/Schema/Request/CallToolRequest.php deleted file mode 100644 index 0674066c..00000000 --- a/src/Schema/Request/CallToolRequest.php +++ /dev/null @@ -1,71 +0,0 @@ - - */ -final class CallToolRequest extends Request -{ - /** - * @param string $name the name of the tool to invoke - * @param array $arguments the arguments to pass to the tool - */ - public function __construct( - public readonly string $name, - public readonly array $arguments, - ) { - } - - public static function getMethod(): string - { - return 'tools/call'; - } - - protected static function fromParams(?array $params): static - { - if (!isset($params['name']) || !\is_string($params['name'])) { - throw new InvalidArgumentException('Missing or invalid "name" parameter for tools/call.'); - } - - $arguments = $params['arguments'] ?? []; - - if ($arguments instanceof \stdClass) { - $arguments = (array) $arguments; - } - - if (!\is_array($arguments)) { - throw new InvalidArgumentException('Parameter "arguments" must be an array.'); - } - - return new self( - $params['name'], - $arguments, - ); - } - - /** - * @return array{name: string, arguments: array} - */ - protected function getParams(): array - { - return [ - 'name' => $this->name, - 'arguments' => $this->arguments ?: new \stdClass(), - ]; - } -} diff --git a/src/Schema/Request/CompletionCompleteRequest.php b/src/Schema/Request/CompletionCompleteRequest.php deleted file mode 100644 index 7ad0332c..00000000 --- a/src/Schema/Request/CompletionCompleteRequest.php +++ /dev/null @@ -1,85 +0,0 @@ - - */ -final class CompletionCompleteRequest extends Request -{ - /** - * @param PromptReference|ResourceReference $ref the prompt or resource to complete - * @param array{ name: string, value: string } $argument the argument to complete - */ - public function __construct( - public readonly PromptReference|ResourceReference $ref, - public readonly array $argument, - ) { - } - - public static function getMethod(): string - { - return 'completion/complete'; - } - - protected static function fromParams(?array $params): static - { - if (!isset($params['ref']) || !\is_array($params['ref'])) { - throw new InvalidArgumentException('Missing or invalid "ref" parameter for completion/complete.'); - } - - $ref = match ($params['ref']['type'] ?? null) { - 'ref/prompt' => new PromptReference(self::refString($params['ref'], 'name')), - 'ref/resource' => new ResourceReference(self::refString($params['ref'], 'uri')), - default => throw new InvalidArgumentException('Invalid "ref" parameter for completion/complete.'), - }; - - if (!isset($params['argument']) || !\is_array($params['argument'])) { - throw new InvalidArgumentException('Missing or invalid "argument" parameter for completion/complete.'); - } - - return new self($ref, $params['argument']); - } - - /** - * @param array $ref - */ - private static function refString(array $ref, string $key): string - { - if (!isset($ref[$key]) || !\is_string($ref[$key])) { - throw new InvalidArgumentException(\sprintf('Missing or invalid "ref.%s" parameter for completion/complete.', $key)); - } - - return $ref[$key]; - } - - /** - * @return array{ - * ref: PromptReference|ResourceReference, - * argument: array{ name: string, value: string } - * } - */ - protected function getParams(): array - { - return [ - 'ref' => $this->ref, - 'argument' => $this->argument, - ]; - } -} diff --git a/src/Schema/Request/CreateSamplingMessageRequest.php b/src/Schema/Request/CreateSamplingMessageRequest.php deleted file mode 100644 index c23b3d8e..00000000 --- a/src/Schema/Request/CreateSamplingMessageRequest.php +++ /dev/null @@ -1,296 +0,0 @@ - - */ -final class CreateSamplingMessageRequest extends Request -{ - /** - * @param SamplingMessage[] $messages the messages to send to the model - * @param int $maxTokens The maximum number of tokens to sample, as requested by the server. - * The client MAY choose to sample fewer tokens than requested. - * @param ?ModelPreferences $preferences The server's preferences for which model to select. The client MAY - * ignore these preferences. - * @param ?string $systemPrompt An optional system prompt the server wants to use for sampling. The - * client MAY modify or omit this prompt. - * @param ?SamplingContext $includeContext A request to include context from one or more MCP servers (including - * the caller), to be attached to the prompt. The client MAY ignore this request. - * Allowed values: "none", "thisServer", "allServers" - * Values other than "none" are soft-deprecated and SHOULD only be sent - * when the client advertises the sampling.context capability. - * @param ?float $temperature The temperature to use for sampling. The client MAY ignore this request. - * @param ?string[] $stopSequences A list of sequences to stop sampling at. The client MAY ignore this request. - * @param ?array $metadata Optional metadata to pass through to the LLM provider. The format of - * this metadata is provider-specific. - * @param ?Tool[] $tools tools that the model may use during generation - * @param ?ToolChoice $toolChoice controls how the model uses tools - */ - public function __construct( - public readonly array $messages, - public readonly int $maxTokens, - public readonly ?ModelPreferences $preferences = null, - public readonly ?string $systemPrompt = null, - public readonly ?SamplingContext $includeContext = null, - public readonly ?float $temperature = null, - public readonly ?array $stopSequences = null, - public readonly ?array $metadata = null, - public readonly ?array $tools = null, - public readonly ?ToolChoice $toolChoice = null, - ) { - foreach ($this->messages as $message) { - if (!$message instanceof SamplingMessage) { - throw new InvalidArgumentException('Messages must be instance of SamplingMessage.'); - } - } - foreach ($this->tools ?? [] as $tool) { - if (!$tool instanceof Tool) { - throw new InvalidArgumentException('Tools must be instances of Tool.'); - } - } - } - - public static function getMethod(): string - { - return 'sampling/createMessage'; - } - - protected static function fromParams(?array $params): static - { - if (!isset($params['messages']) || !\is_array($params['messages'])) { - throw new InvalidArgumentException('Missing or invalid "messages" parameter for sampling/createMessage.'); - } - - if (!isset($params['maxTokens']) || !\is_int($params['maxTokens'])) { - throw new InvalidArgumentException('Missing or invalid "maxTokens" parameter for sampling/createMessage.'); - } - - $messages = []; - foreach ($params['messages'] as $messageData) { - if ($messageData instanceof SamplingMessage) { - $messages[] = $messageData; - } elseif (\is_array($messageData)) { - $messages[] = SamplingMessage::fromArray($messageData); - } else { - throw new InvalidArgumentException('Invalid message format in sampling/createMessage.'); - } - } - - $preferences = null; - if (isset($params['preferences'])) { - if (!\is_array($params['preferences'])) { - throw new InvalidArgumentException('Invalid "preferences" parameter for sampling/createMessage.'); - } - $preferences = ModelPreferences::fromArray($params['preferences']); - } - - $includeContext = null; - if (isset($params['includeContext']) && \is_string($params['includeContext'])) { - $includeContext = SamplingContext::tryFrom($params['includeContext']); - } - - if (isset($params['systemPrompt']) && !\is_string($params['systemPrompt'])) { - throw new InvalidArgumentException('Invalid "systemPrompt" parameter for sampling/createMessage.'); - } - - if (isset($params['temperature']) && !\is_float($params['temperature']) && !\is_int($params['temperature'])) { - throw new InvalidArgumentException('Invalid "temperature" parameter for sampling/createMessage.'); - } - - if (isset($params['stopSequences'])) { - if (!\is_array($params['stopSequences'])) { - throw new InvalidArgumentException('Invalid "stopSequences" parameter for sampling/createMessage.'); - } - - foreach ($params['stopSequences'] as $stopSequence) { - if (!\is_string($stopSequence)) { - throw new InvalidArgumentException('Each entry in "stopSequences" must be a string for sampling/createMessage.'); - } - } - } - - if (isset($params['metadata']) && !\is_array($params['metadata'])) { - throw new InvalidArgumentException('Invalid "metadata" parameter for sampling/createMessage.'); - } - - $tools = null; - if (isset($params['tools'])) { - if (!\is_array($params['tools'])) { - throw new InvalidArgumentException('Invalid "tools" parameter for sampling/createMessage.'); - } - $tools = []; - foreach ($params['tools'] as $toolData) { - if ($toolData instanceof Tool) { - $tools[] = $toolData; - } elseif (\is_array($toolData)) { - $tools[] = Tool::fromArray($toolData); - } else { - throw new InvalidArgumentException('Invalid tool format in sampling/createMessage.'); - } - } - } - - $toolChoice = null; - if (isset($params['toolChoice'])) { - if ($params['toolChoice'] instanceof ToolChoice) { - $toolChoice = $params['toolChoice']; - } elseif (\is_array($params['toolChoice'])) { - $toolChoice = ToolChoice::fromArray($params['toolChoice']); - } else { - throw new InvalidArgumentException('Invalid "toolChoice" parameter for sampling/createMessage.'); - } - } - - return new self( - $messages, - $params['maxTokens'], - $preferences, - $params['systemPrompt'] ?? null, - $includeContext, - isset($params['temperature']) ? (float) $params['temperature'] : null, - $params['stopSequences'] ?? null, - $params['metadata'] ?? null, - $tools, - $toolChoice, - ); - } - - /** - * Assert the spec's tool-flow rules over the whole message list. - * - * These are deliberately kept out of the hydration path: a violation is an - * "invalid params" condition the peer must be told about, not a parse failure - * that would leave the request unanswered. Call it from whatever boundary can - * report it — the request handler when receiving, the gateway when sending. - * - * @throws InvalidArgumentException on the first violation found - */ - public function validateToolFlow(): void - { - $pendingToolUseIds = []; - - foreach ($this->messages as $message) { - $blocks = $message->getContentBlocks(); - - $toolResults = array_filter($blocks, static fn ($block): bool => $block instanceof ToolResultContent); - $toolUses = array_filter($blocks, static fn ($block): bool => $block instanceof ToolUseContent); - - if ($toolResults && \count($toolResults) !== \count($blocks)) { - throw new InvalidArgumentException('Tool results mixed with other content.'); - } - - if (Role::User === $message->role && $toolUses) { - throw new InvalidArgumentException('ToolUseContent is only valid in assistant sampling messages.'); - } - - if (Role::Assistant === $message->role && $toolResults) { - throw new InvalidArgumentException('ToolResultContent is only valid in user sampling messages.'); - } - - if ($pendingToolUseIds && !$toolResults) { - throw new InvalidArgumentException('Tool result missing in request.'); - } - - foreach ($toolResults as $toolResult) { - $matched = array_search($toolResult->toolUseId, $pendingToolUseIds, true); - if (false === $matched) { - throw new InvalidArgumentException(\sprintf('Tool result "%s" does not answer a preceding tool use.', $toolResult->toolUseId)); - } - unset($pendingToolUseIds[$matched]); - } - - if ($pendingToolUseIds) { - throw new InvalidArgumentException('Tool result missing in request.'); - } - - foreach ($toolUses as $toolUse) { - $pendingToolUseIds[] = $toolUse->id; - } - } - - if ($pendingToolUseIds) { - throw new InvalidArgumentException('Tool result missing in request.'); - } - } - - /** - * @return array{ - * messages: SamplingMessage[], - * maxTokens: int, - * preferences?: ModelPreferences, - * systemPrompt?: string, - * includeContext?: string, - * temperature?: float, - * stopSequences?: string[], - * metadata?: array, - * tools?: Tool[], - * toolChoice?: ToolChoice, - * } - */ - protected function getParams(): array - { - $params = [ - 'messages' => $this->messages, - 'maxTokens' => $this->maxTokens, - ]; - - if (null !== $this->preferences) { - $params['preferences'] = $this->preferences; - } - - if (null !== $this->systemPrompt) { - $params['systemPrompt'] = $this->systemPrompt; - } - - if (null !== $this->includeContext) { - $params['includeContext'] = $this->includeContext->value; - } - - if (null !== $this->temperature) { - $params['temperature'] = $this->temperature; - } - - if (null !== $this->stopSequences) { - $params['stopSequences'] = $this->stopSequences; - } - - if (null !== $this->metadata) { - $params['metadata'] = $this->metadata; - } - - if (null !== $this->tools) { - $params['tools'] = $this->tools; - } - - if (null !== $this->toolChoice) { - $params['toolChoice'] = $this->toolChoice; - } - - return $params; - } -} diff --git a/src/Schema/Request/ElicitRequest.php b/src/Schema/Request/ElicitRequest.php deleted file mode 100644 index ab56e10c..00000000 --- a/src/Schema/Request/ElicitRequest.php +++ /dev/null @@ -1,72 +0,0 @@ - - */ -final class ElicitRequest extends Request -{ - /** - * @param string $message A human-readable message describing what information is needed - * @param ElicitationSchema $requestedSchema The schema defining the fields to elicit from the user - */ - public function __construct( - public readonly string $message, - public readonly ElicitationSchema $requestedSchema, - ) { - } - - public static function getMethod(): string - { - return 'elicitation/create'; - } - - protected static function fromParams(?array $params): static - { - if (!isset($params['message']) || !\is_string($params['message'])) { - throw new InvalidArgumentException('Missing or invalid "message" parameter for elicitation/create.'); - } - - if (!isset($params['requestedSchema']) || !\is_array($params['requestedSchema'])) { - throw new InvalidArgumentException('Missing or invalid "requestedSchema" parameter for elicitation/create.'); - } - - return new self( - $params['message'], - ElicitationSchema::fromArray($params['requestedSchema']), - ); - } - - /** - * @return array{ - * message: string, - * requestedSchema: ElicitationSchema, - * } - */ - protected function getParams(): array - { - return [ - 'message' => $this->message, - 'requestedSchema' => $this->requestedSchema, - ]; - } -} diff --git a/src/Schema/Request/GetPromptRequest.php b/src/Schema/Request/GetPromptRequest.php deleted file mode 100644 index 5d91ff55..00000000 --- a/src/Schema/Request/GetPromptRequest.php +++ /dev/null @@ -1,71 +0,0 @@ - - */ -final class GetPromptRequest extends Request -{ - /** - * @param string $name the name of the prompt to get - * @param array|null $arguments the arguments to pass to the prompt - */ - public function __construct( - public readonly string $name, - public readonly ?array $arguments = null, - ) { - } - - public static function getMethod(): string - { - return 'prompts/get'; - } - - protected static function fromParams(?array $params): static - { - if (!isset($params['name']) || !\is_string($params['name']) || empty($params['name'])) { - throw new InvalidArgumentException('Missing or invalid "name" parameter for prompts/get.'); - } - - $arguments = $params['arguments'] ?? null; - if (null !== $arguments) { - if ($arguments instanceof \stdClass) { - $arguments = (array) $arguments; - } - if (!\is_array($arguments)) { - throw new InvalidArgumentException('Parameter "arguments" must be an array for prompts/get.'); - } - } - - return new self($params['name'], $arguments); - } - - /** - * @return array{name: string, arguments?: array} - */ - protected function getParams(): array - { - $params = ['name' => $this->name]; - - if (null !== $this->arguments) { - $params['arguments'] = $this->arguments; - } - - return $params; - } -} diff --git a/src/Schema/Request/InitializeRequest.php b/src/Schema/Request/InitializeRequest.php deleted file mode 100644 index f7b74601..00000000 --- a/src/Schema/Request/InitializeRequest.php +++ /dev/null @@ -1,73 +0,0 @@ - - */ -final class InitializeRequest extends Request -{ - /** - * @param string $protocolVersion The latest version of the Model Context Protocol that the client supports. The client MAY decide to support older versions as well. - * @param ClientCapabilities $capabilities the capabilities of the client - * @param Implementation $clientInfo information about the client - */ - public function __construct( - public readonly string $protocolVersion, - public readonly ClientCapabilities $capabilities, - public readonly Implementation $clientInfo, - ) { - } - - public static function getMethod(): string - { - return 'initialize'; - } - - protected static function fromParams(?array $params): static - { - if (!isset($params['protocolVersion']) || !\is_string($params['protocolVersion'])) { - throw new InvalidArgumentException('Missing or invalid "protocolVersion" parameter for initialize.'); - } - - if (!isset($params['capabilities']) || !\is_array($params['capabilities'])) { - throw new InvalidArgumentException('Missing or invalid "capabilities" parameter for initialize.'); - } - $capabilities = ClientCapabilities::fromArray($params['capabilities']); - - if (!isset($params['clientInfo']) || !\is_array($params['clientInfo'])) { - throw new InvalidArgumentException('Missing or invalid "clientInfo" parameter for initialize.'); - } - $clientInfo = Implementation::fromArray($params['clientInfo']); - - return new self($params['protocolVersion'], $capabilities, $clientInfo); - } - - /** - * @return array{protocolVersion: string, capabilities: ClientCapabilities, clientInfo: Implementation} - */ - protected function getParams(): array - { - return [ - 'protocolVersion' => $this->protocolVersion, - 'capabilities' => $this->capabilities, - 'clientInfo' => $this->clientInfo, - ]; - } -} diff --git a/src/Schema/Request/ListPromptsRequest.php b/src/Schema/Request/ListPromptsRequest.php deleted file mode 100644 index 3f1cff0e..00000000 --- a/src/Schema/Request/ListPromptsRequest.php +++ /dev/null @@ -1,60 +0,0 @@ - - */ -final class ListPromptsRequest extends Request -{ - /** - * If provided, the server should return results starting after this cursor. - * - * @param string|null $cursor an opaque token representing the current pagination position - */ - public function __construct( - public readonly ?string $cursor = null, - ) { - } - - public static function getMethod(): string - { - return 'prompts/list'; - } - - protected static function fromParams(?array $params): static - { - if (isset($params['cursor']) && !\is_string($params['cursor'])) { - throw new InvalidArgumentException('Invalid "cursor" parameter for prompts/list.'); - } - - return new self($params['cursor'] ?? null); - } - - /** - * @return array{cursor:string}|null - */ - protected function getParams(): ?array - { - $params = []; - if (null !== $this->cursor) { - $params['cursor'] = $this->cursor; - } - - return $params ?: null; - } -} diff --git a/src/Schema/Request/ListResourceTemplatesRequest.php b/src/Schema/Request/ListResourceTemplatesRequest.php deleted file mode 100644 index 1822c57e..00000000 --- a/src/Schema/Request/ListResourceTemplatesRequest.php +++ /dev/null @@ -1,60 +0,0 @@ - - */ -final class ListResourceTemplatesRequest extends Request -{ - /** - * @param string|null $cursor An opaque token representing the current pagination position. - * - * If provided, the server should return results starting after this cursor. - */ - public function __construct( - public readonly ?string $cursor = null, - ) { - } - - public static function getMethod(): string - { - return 'resources/templates/list'; - } - - protected static function fromParams(?array $params): static - { - if (isset($params['cursor']) && !\is_string($params['cursor'])) { - throw new InvalidArgumentException('Invalid "cursor" parameter for resources/templates/list.'); - } - - return new self($params['cursor'] ?? null); - } - - /** - * @return array{cursor:string}|null - */ - protected function getParams(): ?array - { - $params = []; - if (null !== $this->cursor) { - $params['cursor'] = $this->cursor; - } - - return $params ?: null; - } -} diff --git a/src/Schema/Request/ListResourcesRequest.php b/src/Schema/Request/ListResourcesRequest.php deleted file mode 100644 index a8fe7727..00000000 --- a/src/Schema/Request/ListResourcesRequest.php +++ /dev/null @@ -1,60 +0,0 @@ - - */ -final class ListResourcesRequest extends Request -{ - /** - * @param string|null $cursor An opaque token representing the current pagination position. - * - * If provided, the server should return results starting after this cursor. - */ - public function __construct( - public readonly ?string $cursor = null, - ) { - } - - public static function getMethod(): string - { - return 'resources/list'; - } - - protected static function fromParams(?array $params): static - { - if (isset($params['cursor']) && !\is_string($params['cursor'])) { - throw new InvalidArgumentException('Invalid "cursor" parameter for resources/list.'); - } - - return new self($params['cursor'] ?? null); - } - - /** - * @return array{cursor:string}|null - */ - protected function getParams(): ?array - { - $params = []; - if (null !== $this->cursor) { - $params['cursor'] = $this->cursor; - } - - return $params ?: null; - } -} diff --git a/src/Schema/Request/ListRootsRequest.php b/src/Schema/Request/ListRootsRequest.php deleted file mode 100644 index 35c8a009..00000000 --- a/src/Schema/Request/ListRootsRequest.php +++ /dev/null @@ -1,47 +0,0 @@ - - */ -final class ListRootsRequest extends Request -{ - public function __construct( - ) { - } - - public static function getMethod(): string - { - return 'roots/list'; - } - - protected static function fromParams(?array $params): static - { - return new self(); - } - - protected function getParams(): ?array - { - return null; - } -} diff --git a/src/Schema/Request/ListToolsRequest.php b/src/Schema/Request/ListToolsRequest.php deleted file mode 100644 index 83cf8875..00000000 --- a/src/Schema/Request/ListToolsRequest.php +++ /dev/null @@ -1,60 +0,0 @@ - - */ -final class ListToolsRequest extends Request -{ - /** - * @param string|null $cursor An opaque token representing the current pagination position. - * - * If provided, the server should return results starting after this cursor. - */ - public function __construct( - public readonly ?string $cursor = null, - ) { - } - - public static function getMethod(): string - { - return 'tools/list'; - } - - protected static function fromParams(?array $params): static - { - if (isset($params['cursor']) && !\is_string($params['cursor'])) { - throw new InvalidArgumentException('Invalid "cursor" parameter for tools/list.'); - } - - return new self($params['cursor'] ?? null); - } - - /** - * @return array{cursor:string}|null - */ - protected function getParams(): ?array - { - $params = []; - if (null !== $this->cursor) { - $params['cursor'] = $this->cursor; - } - - return $params ?: null; - } -} diff --git a/src/Schema/Request/PingRequest.php b/src/Schema/Request/PingRequest.php deleted file mode 100644 index 31fe64bb..00000000 --- a/src/Schema/Request/PingRequest.php +++ /dev/null @@ -1,38 +0,0 @@ - - */ -final class PingRequest extends Request -{ - public static function getMethod(): string - { - return 'ping'; - } - - protected static function fromParams(?array $params): static - { - return new self(); - } - - protected function getParams(): ?array - { - return null; - } -} diff --git a/src/Schema/Request/ReadResourceRequest.php b/src/Schema/Request/ReadResourceRequest.php deleted file mode 100644 index 69523eff..00000000 --- a/src/Schema/Request/ReadResourceRequest.php +++ /dev/null @@ -1,55 +0,0 @@ - - */ -final class ReadResourceRequest extends Request -{ - /** - * @param non-empty-string $uri the URI of the resource to read - */ - public function __construct( - public readonly string $uri, - ) { - } - - public static function getMethod(): string - { - return 'resources/read'; - } - - protected static function fromParams(?array $params): static - { - if (!isset($params['uri']) || !\is_string($params['uri']) || empty($params['uri'])) { - throw new InvalidArgumentException('Missing or invalid "uri" parameter for resources/read.'); - } - - return new self($params['uri']); - } - - /** - * @return array{uri: non-empty-string} - */ - protected function getParams(): array - { - return [ - 'uri' => $this->uri, - ]; - } -} diff --git a/src/Schema/Request/ResourceSubscribeRequest.php b/src/Schema/Request/ResourceSubscribeRequest.php deleted file mode 100644 index 036785a2..00000000 --- a/src/Schema/Request/ResourceSubscribeRequest.php +++ /dev/null @@ -1,54 +0,0 @@ - - */ -final class ResourceSubscribeRequest extends Request -{ - /** - * @param non-empty-string $uri the URI of the resource to subscribe to - */ - public function __construct( - public readonly string $uri, - ) { - } - - public static function getMethod(): string - { - return 'resources/subscribe'; - } - - protected static function fromParams(?array $params): static - { - if (!isset($params['uri']) || !\is_string($params['uri']) || empty($params['uri'])) { - throw new InvalidArgumentException('Missing or invalid "uri" parameter for resources/subscribe.'); - } - - return new self($params['uri']); - } - - /** - * @return array{uri: non-empty-string} - */ - protected function getParams(): array - { - return ['uri' => $this->uri]; - } -} diff --git a/src/Schema/Request/ResourceUnsubscribeRequest.php b/src/Schema/Request/ResourceUnsubscribeRequest.php deleted file mode 100644 index fd93727b..00000000 --- a/src/Schema/Request/ResourceUnsubscribeRequest.php +++ /dev/null @@ -1,54 +0,0 @@ - - */ -final class ResourceUnsubscribeRequest extends Request -{ - /** - * @param non-empty-string $uri the URI of the resource to unsubscribe from - */ - public function __construct( - public readonly string $uri, - ) { - } - - public static function getMethod(): string - { - return 'resources/unsubscribe'; - } - - protected static function fromParams(?array $params): static - { - if (!isset($params['uri']) || !\is_string($params['uri']) || empty($params['uri'])) { - throw new InvalidArgumentException('Missing or invalid "uri" parameter for resources/unsubscribe.'); - } - - return new self($params['uri']); - } - - /** - * @return array{uri: non-empty-string} - */ - protected function getParams(): array - { - return ['uri' => $this->uri]; - } -} diff --git a/src/Schema/Request/SetLogLevelRequest.php b/src/Schema/Request/SetLogLevelRequest.php deleted file mode 100644 index 1a441ccb..00000000 --- a/src/Schema/Request/SetLogLevelRequest.php +++ /dev/null @@ -1,62 +0,0 @@ - - */ -final class SetLogLevelRequest extends Request -{ - /** - * @param LoggingLevel $level The level of logging that the client wants to receive from the server. The server - * should send all logs at this level and higher (i.e., more severe) to the client as - * notifications/message. - */ - public function __construct( - public readonly LoggingLevel $level, - ) { - } - - public static function getMethod(): string - { - return 'logging/setLevel'; - } - - protected static function fromParams(?array $params): static - { - if (!isset($params['level']) || !\is_string($params['level']) || '' === $params['level']) { - throw new InvalidArgumentException('Missing or invalid "level" parameter for "logging/setLevel".'); - } - - if (null === $level = LoggingLevel::tryFrom($params['level'])) { - throw new InvalidArgumentException(\sprintf('Invalid "level" parameter "%s" for "logging/setLevel".', $params['level'])); - } - - return new self($level); - } - - /** - * @return array{level: value-of} - */ - protected function getParams(): array - { - return [ - 'level' => $this->level->value, - ]; - } -} diff --git a/src/Schema/ResourceDefinition.php b/src/Schema/ResourceDefinition.php deleted file mode 100644 index ca9d0e65..00000000 --- a/src/Schema/ResourceDefinition.php +++ /dev/null @@ -1,160 +0,0 @@ -, - * } - * - * @author Kyrian Obikwelu - */ -class ResourceDefinition implements \JsonSerializable -{ - /** - * Resource name pattern regex - must contain only alphanumeric characters, underscores, and hyphens. - */ - private const RESOURCE_NAME_PATTERN = '/^[a-zA-Z0-9_-]+$/'; - - /** - * URI pattern regex - requires a valid scheme followed by colon and optional path (RFC 3986). - * Example patterns: file://path, db://table, urn:isbn:123, config:key, etc. - */ - private const URI_PATTERN = '/^[a-zA-Z][a-zA-Z0-9+.-]*:[^\s]*$/'; - - /** - * @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, - ) { - if (!preg_match(self::RESOURCE_NAME_PATTERN, $name)) { - throw new InvalidArgumentException(\sprintf('Invalid resource name "%s": must contain only alphanumeric characters, underscores, and hyphens.', $name)); - } - if (!preg_match(self::URI_PATTERN, $uri)) { - throw new InvalidArgumentException(\sprintf('Invalid resource URI: "%s" must be a valid URI with a scheme and optional path.', $uri)); - } - } - - /** - * @param ResourceDefinitionData $data - */ - public static function fromArray(array $data): self - { - if (empty($data['uri']) || !\is_string($data['uri'])) { - throw new InvalidArgumentException('Invalid or missing "uri" in ResourceDefinition data.'); - } - if (empty($data['name']) || !\is_string($data['name'])) { - throw new InvalidArgumentException('Invalid or missing "name" in ResourceDefinition data.'); - } - - if (isset($data['_meta']) && !\is_array($data['_meta'])) { - throw new InvalidArgumentException('Invalid "_meta" in ResourceDefinition data.'); - } - if (isset($data['description']) && !\is_string($data['description'])) { - throw new InvalidArgumentException('Invalid "description" in ResourceDefinition data.'); - } - if (isset($data['mimeType']) && !\is_string($data['mimeType'])) { - throw new InvalidArgumentException('Invalid "mimeType" in ResourceDefinition data.'); - } - if (isset($data['size']) && !\is_int($data['size'])) { - throw new InvalidArgumentException('Invalid "size" in ResourceDefinition 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, 'ResourceDefinition'), - size: $data['size'] ?? null, - icons: isset($data['icons']) && \is_array($data['icons']) ? Icon::listFromArray($data['icons'], 'ResourceDefinition') : null, - meta: isset($data['_meta']) ? $data['_meta'] : null - ); - } - - /** - * @return array{ - * uri: string, - * name: string, - * title?: string, - * description?: string, - * mimeType?: string, - * annotations?: Annotations, - * size?: int, - * icons?: Icon[], - * _meta?: array - * } - */ - public function jsonSerialize(): array - { - $data = [ - '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/ResourceReference.php b/src/Schema/ResourceReference.php deleted file mode 100644 index c861c7e2..00000000 --- a/src/Schema/ResourceReference.php +++ /dev/null @@ -1,44 +0,0 @@ - - */ -class ResourceReference implements \JsonSerializable -{ - public string $type = 'ref/resource'; - - /** - * @param string $uri the URI or URI template of the resource - */ - public function __construct( - public readonly string $uri, - ) { - } - - /** - * @return array{ - * type: string, - * uri: string, - * } - */ - public function jsonSerialize(): array - { - return [ - 'type' => $this->type, - 'uri' => $this->uri, - ]; - } -} diff --git a/src/Schema/ResourceTemplate.php b/src/Schema/ResourceTemplate.php deleted file mode 100644 index a26e8b92..00000000 --- a/src/Schema/ResourceTemplate.php +++ /dev/null @@ -1,140 +0,0 @@ - - * } - * - * @author Kyrian Obikwelu - */ -class ResourceTemplate implements \JsonSerializable -{ - /** - * Resource name pattern regex - must contain only alphanumeric characters, underscores, and hyphens. - */ - private const RESOURCE_NAME_PATTERN = '/^[a-zA-Z0-9_-]+$/'; - - /** - * URI Template pattern regex - requires a valid scheme followed by colon and path with at least one placeholder (RFC 3986). - * Example patterns: file://{path}/contents.txt, db://{table}/{id}, config:{key}, etc. - */ - private const URI_TEMPLATE_PATTERN = '/^[a-zA-Z][a-zA-Z0-9+.-]*:.*{[^{}]+}.*/'; - - /** - * @param string $uriTemplate a URI template (according to RFC 6570) that can be used to construct resource URIs - * @param string $name a short identifier for this resource template type - * @param ?string $title optional human-readable title for display in UI - * @param ?string $description a description to help the LLM understand available resources - * @param ?string $mimeType the MIME type for all resources that match this template, if uniform - * @param ?Annotations $annotations optional annotations for the client - * @param ?array $meta optional metadata - */ - public function __construct( - public readonly string $uriTemplate, - 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 ?array $meta = null, - ) { - if (!preg_match(self::RESOURCE_NAME_PATTERN, $name)) { - throw new InvalidArgumentException(\sprintf('Invalid resource name "%s": must contain only alphanumeric characters, underscores, and hyphens.', $name)); - } - if (!preg_match(self::URI_TEMPLATE_PATTERN, $uriTemplate)) { - throw new InvalidArgumentException(\sprintf('Invalid URI template : "%s" must be a valid URI template with at least one placeholder.', $uriTemplate)); - } - } - - /** - * @param ResourceTemplateData $data - */ - public static function fromArray(array $data): self - { - if (empty($data['uriTemplate']) || !\is_string($data['uriTemplate'])) { - throw new InvalidArgumentException('Invalid or missing "uriTemplate" in ResourceTemplate data.'); - } - if (empty($data['name']) || !\is_string($data['name'])) { - throw new InvalidArgumentException('Invalid or missing "name" in ResourceTemplate data.'); - } - - if (isset($data['_meta']) && !\is_array($data['_meta'])) { - throw new InvalidArgumentException('Invalid "_meta" in ResourceTemplate data.'); - } - if (isset($data['description']) && !\is_string($data['description'])) { - throw new InvalidArgumentException('Invalid "description" in ResourceTemplate data.'); - } - if (isset($data['mimeType']) && !\is_string($data['mimeType'])) { - throw new InvalidArgumentException('Invalid "mimeType" in ResourceTemplate data.'); - } - - return new self( - uriTemplate: $data['uriTemplate'], - 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, 'ResourceTemplate'), - meta: isset($data['_meta']) ? $data['_meta'] : null - ); - } - - /** - * @return array{ - * uriTemplate: string, - * name: string, - * title?: string, - * description?: string, - * mimeType?: string, - * annotations?: Annotations, - * _meta?: array - * } - */ - public function jsonSerialize(): array - { - $data = [ - 'uriTemplate' => $this->uriTemplate, - '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->meta) { - $data['_meta'] = $this->meta; - } - - return $data; - } -} diff --git a/src/Schema/Result/CallToolResult.php b/src/Schema/Result/CallToolResult.php deleted file mode 100644 index c6618395..00000000 --- a/src/Schema/Result/CallToolResult.php +++ /dev/null @@ -1,157 +0,0 @@ - - */ -class CallToolResult implements ResultInterface -{ - /** - * Create a new CallToolResult. - * - * @param Content[] $content The content of the tool result - * @param bool $isError Whether the tool execution resulted in an error. If not set, this is assumed to be false (the call was successful). - * @param mixed[] $structuredContent JSON content for `structuredContent` - * @param array|null $meta Optional metadata - */ - public function __construct( - public readonly array $content, - public readonly bool $isError = false, - public readonly ?array $structuredContent = null, - public readonly ?array $meta = null, - ) { - foreach ($this->content as $item) { - if (!$item instanceof Content) { - throw new InvalidArgumentException('Content must be an array of Content objects.'); - } - } - } - - /** - * Create a new CallToolResult with success status. - * - * @param Content[] $content The content of the tool result - * @param array|null $meta Optional metadata - */ - public static function success(array $content, ?array $meta = null): self - { - return new self($content, false, null, $meta); - } - - /** - * Create a new CallToolResult with error status. - * - * @param Content[] $content The content of the tool result - * @param array|null $meta Optional metadata - */ - public static function error(array $content, ?array $meta = null): self - { - return new self($content, true, null, $meta); - } - - /** - * @param array{ - * content: array, - * isError?: bool, - * _meta?: array, - * structuredContent?: array - * } $data - */ - public static function fromArray(array $data): self - { - if (!isset($data['content']) || !\is_array($data['content'])) { - throw new InvalidArgumentException('Missing or invalid "content" array in CallToolResult data.'); - } - - $contents = []; - - foreach ($data['content'] as $item) { - $type = \is_array($item) ? $item['type'] ?? null : null; - if (!\is_string($type)) { - throw new InvalidArgumentException('Missing or invalid content "type" in CallToolResult data.'); - } - - $contents[] = match ($type) { - 'text' => TextContent::fromArray($item), - 'image' => ImageContent::fromArray($item), - 'audio' => AudioContent::fromArray($item), - 'resource' => EmbeddedResource::fromArray($item), - 'resource_link' => ResourceLink::fromArray($item), - default => throw new InvalidArgumentException(\sprintf('Invalid content type in CallToolResult data: "%s".', $type)), - }; - } - - if (isset($data['isError']) && !\is_bool($data['isError'])) { - throw new InvalidArgumentException('Invalid "isError" in CallToolResult data.'); - } - if (isset($data['structuredContent']) && !\is_array($data['structuredContent'])) { - throw new InvalidArgumentException('Invalid "structuredContent" in CallToolResult data.'); - } - if (isset($data['_meta']) && !\is_array($data['_meta'])) { - throw new InvalidArgumentException('Invalid "_meta" in CallToolResult data.'); - } - - return new self( - $contents, - $data['isError'] ?? false, - $data['structuredContent'] ?? null, - $data['_meta'] ?? null - ); - } - - /** - * @return array{ - * content: array, - * isError: bool, - * structuredContent?: array, - * _meta?: array, - * } - */ - public function jsonSerialize(): array - { - $result = [ - 'content' => $this->content, - 'isError' => $this->isError, - ]; - - if ($this->structuredContent) { - $result['structuredContent'] = $this->structuredContent; - } - - if ($this->meta) { - $result['_meta'] = $this->meta; - } - - return $result; - } -} diff --git a/src/Schema/Result/CompletionCompleteResult.php b/src/Schema/Result/CompletionCompleteResult.php deleted file mode 100644 index 43c9fed4..00000000 --- a/src/Schema/Result/CompletionCompleteResult.php +++ /dev/null @@ -1,89 +0,0 @@ - - */ -class CompletionCompleteResult implements ResultInterface -{ - /** - * @param string[] $values An array of completion values. Must not exceed 100 items. - * @param int|null $total The total number of completion options available. This can exceed the number of values actually sent in the response. - * @param bool|null $hasMore indicates whether there are additional completion options beyond those provided in the current response, even if the exact total is unknown - */ - public function __construct( - public readonly array $values, - public readonly ?int $total = null, - public readonly ?bool $hasMore = null, - ) { - if (\count($this->values) > 100) { - throw new InvalidArgumentException('Values must not exceed 100 items'); - } - } - - /** - * @return array{ - * completion: array{ - * values: string[], - * total?: int, - * hasMore?: bool, - * } - * } - */ - public function jsonSerialize(): array - { - $completion = [ - 'values' => $this->values, - ]; - - if (null !== $this->total) { - $completion['total'] = $this->total; - } - if (null !== $this->hasMore) { - $completion['hasMore'] = $this->hasMore; - } - - return ['completion' => $completion]; - } - - /** - * @param array $data - */ - public static function fromArray(array $data): self - { - $completion = $data['completion'] ?? []; - if (!\is_array($completion)) { - throw new InvalidArgumentException('Invalid "completion" in CompletionCompleteResult data.'); - } - if (isset($completion['values']) && !\is_array($completion['values'])) { - throw new InvalidArgumentException('Invalid "completion.values" in CompletionCompleteResult data.'); - } - if (isset($completion['total']) && !\is_int($completion['total'])) { - throw new InvalidArgumentException('Invalid "completion.total" in CompletionCompleteResult data.'); - } - if (isset($completion['hasMore']) && !\is_bool($completion['hasMore'])) { - throw new InvalidArgumentException('Invalid "completion.hasMore" in CompletionCompleteResult data.'); - } - - return new self( - $completion['values'] ?? [], - $completion['total'] ?? null, - $completion['hasMore'] ?? null, - ); - } -} diff --git a/src/Schema/Result/CreateSamplingMessageResult.php b/src/Schema/Result/CreateSamplingMessageResult.php deleted file mode 100644 index 3a50a4a6..00000000 --- a/src/Schema/Result/CreateSamplingMessageResult.php +++ /dev/null @@ -1,174 +0,0 @@ - - */ -class CreateSamplingMessageResult implements ResultInterface -{ - /** - * @var TextContent|ImageContent|AudioContent|ToolUseContent|list - */ - public readonly TextContent|ImageContent|AudioContent|ToolUseContent|array $content; - - /** - * @param Role $role the role of the message - * @param TextContent|ImageContent|AudioContent|ToolUseContent|array $content The content of the message. Keys are discarded, the property always holds a list. - * @param string $model the name of the model that generated the message - * @param ?string $stopReason The reason why sampling stopped, if known. The spec defines "endTurn", - * "stopSequence", "maxTokens" and "toolUse", but leaves the set open for - * provider-specific values, so this stays an unconstrained string. - * @param ?array $meta optional message metadata - */ - public function __construct( - public readonly Role $role, - TextContent|ImageContent|AudioContent|ToolUseContent|array $content, - public readonly string $model, - public readonly ?string $stopReason = null, - public readonly ?array $meta = null, - ) { - if (Role::Assistant !== $role) { - throw new InvalidArgumentException('CreateSamplingMessageResult role must be "assistant".'); - } - - if (\is_array($content)) { - if ([] === $content) { - throw new InvalidArgumentException('CreateSamplingMessageResult content must not be empty.'); - } - - foreach ($content as $item) { - if (!$item instanceof TextContent && !$item instanceof ImageContent && !$item instanceof AudioContent && !$item instanceof ToolUseContent) { - throw new InvalidArgumentException('CreateSamplingMessageResult 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; - } - - /** - * @return list - */ - public function getContentBlocks(): array - { - return \is_array($this->content) ? $this->content : [$this->content]; - } - - /** - * @param array $data - */ - public static function fromArray(array $data): self - { - if (!isset($data['role']) || !\is_string($data['role'])) { - throw new InvalidArgumentException('Missing or invalid "role" in CreateSamplingMessageResult data.'); - } - - if (!isset($data['content']) || !\is_array($data['content']) || [] === $data['content']) { - throw new InvalidArgumentException('Missing or invalid "content" in CreateSamplingMessageResult data.'); - } - - if (!isset($data['model']) || !\is_string($data['model'])) { - throw new InvalidArgumentException('Missing or invalid "model" in CreateSamplingMessageResult data.'); - } - - if (null === $role = Role::tryFrom($data['role'])) { - throw new InvalidArgumentException(\sprintf('Invalid "role" value "%s" in CreateSamplingMessageResult data.', $data['role'])); - } - - $contentPayload = $data['content']; - - $isSingleContent = isset($contentPayload['type']); - $contentItems = $isSingleContent ? [$contentPayload] : $contentPayload; - $content = []; - foreach ($contentItems as $item) { - if (!\is_array($item)) { - throw new InvalidArgumentException('Invalid content block in CreateSamplingMessageResult data.'); - } - $content[] = self::hydrateContent($item); - } - - $stopReason = isset($data['stopReason']) && \is_string($data['stopReason']) ? $data['stopReason'] : null; - - return new self( - $role, - $isSingleContent ? $content[0] : $content, - $data['model'], - $stopReason, - isset($data['_meta']) && \is_array($data['_meta']) ? $data['_meta'] : null, - ); - } - - /** - * @param array $contentData - */ - private static function hydrateContent(array $contentData): TextContent|ImageContent|AudioContent|ToolUseContent - { - $type = $contentData['type'] ?? null; - - if (!\is_string($type)) { - throw new InvalidArgumentException('Missing or invalid "type" in sampling content payload.'); - } - - return match ($type) { - 'text' => TextContent::fromArray($contentData), - 'image' => ImageContent::fromArray($contentData), - 'audio' => AudioContent::fromArray($contentData), - 'tool_use' => ToolUseContent::fromArray($contentData), - default => throw new InvalidArgumentException(\sprintf('Unsupported sampling content type "%s".', $type)), - }; - } - - /** - * @return array{ - * role: string, - * content: TextContent|ImageContent|AudioContent|ToolUseContent|list, - * model: string, - * stopReason?: string, - * _meta?: array, - * } - */ - public function jsonSerialize(): array - { - $result = [ - 'role' => $this->role->value, - 'content' => $this->content, - 'model' => $this->model, - ]; - - if (null !== $this->stopReason) { - $result['stopReason'] = $this->stopReason; - } - - if (null !== $this->meta) { - $result['_meta'] = $this->meta; - } - - return $result; - } -} diff --git a/src/Schema/Result/ElicitResult.php b/src/Schema/Result/ElicitResult.php deleted file mode 100644 index 70415959..00000000 --- a/src/Schema/Result/ElicitResult.php +++ /dev/null @@ -1,90 +0,0 @@ - - */ -final class ElicitResult implements ResultInterface -{ - /** - * @param ElicitAction $action The user's action in response to the elicitation - * @param array|null $content The content provided by the user (only present when action is "accept") - */ - public function __construct( - public readonly ElicitAction $action, - public readonly ?array $content = null, - ) { - } - - /** - * @param array{action: string, content?: array} $data - */ - public static function fromArray(array $data): self - { - if (!isset($data['action']) || !\is_string($data['action'])) { - throw new InvalidArgumentException('Missing or invalid "action" in ElicitResult data.'); - } - - if (null === $action = ElicitAction::tryFrom($data['action'])) { - throw new InvalidArgumentException(\sprintf('Invalid "action" value "%s" in ElicitResult data.', $data['action'])); - } - - $content = isset($data['content']) && \is_array($data['content']) ? $data['content'] : null; - - if (ElicitAction::Accept === $action && null === $content) { - throw new InvalidArgumentException('Content must be provided when action is "accept".'); - } - - return new self($action, $content); - } - - public function isAccepted(): bool - { - return ElicitAction::Accept === $this->action; - } - - public function isDeclined(): bool - { - return ElicitAction::Decline === $this->action; - } - - public function isCancelled(): bool - { - return ElicitAction::Cancel === $this->action; - } - - /** - * @return array{action: string, content?: array} - */ - public function jsonSerialize(): array - { - $result = [ - 'action' => $this->action->value, - ]; - - if (null !== $this->content) { - $result['content'] = $this->content; - } - - return $result; - } -} diff --git a/src/Schema/Result/EmptyResult.php b/src/Schema/Result/EmptyResult.php deleted file mode 100644 index 26416983..00000000 --- a/src/Schema/Result/EmptyResult.php +++ /dev/null @@ -1,39 +0,0 @@ - - */ -class EmptyResult implements ResultInterface -{ - /** - * Create a new EmptyResult. - */ - public function __construct() - { - } - - public static function fromArray(): self - { - return new self(); - } - - public function jsonSerialize(): object - { - return new \stdClass(); - } -} diff --git a/src/Schema/Result/GetPromptResult.php b/src/Schema/Result/GetPromptResult.php deleted file mode 100644 index 0083a83e..00000000 --- a/src/Schema/Result/GetPromptResult.php +++ /dev/null @@ -1,88 +0,0 @@ - - */ -class GetPromptResult implements ResultInterface -{ - /** - * Create a new GetPromptResult. - * - * @param PromptMessage[] $messages The messages in the prompt - * @param string|null $description Optional description of the prompt - */ - public function __construct( - public readonly array $messages, - public readonly ?string $description = null, - ) { - foreach ($this->messages as $message) { - if (!$message instanceof PromptMessage) { - throw new InvalidArgumentException('Messages must be an array of PromptMessage objects.'); - } - } - } - - /** - * @param array{ - * messages: array, - * description?: string, - * } $data - */ - public static function fromArray(array $data): self - { - if (!isset($data['messages']) || !\is_array($data['messages'])) { - throw new InvalidArgumentException('Missing or invalid "messages" array in GetPromptResult data.'); - } - - if (isset($data['description']) && !\is_string($data['description'])) { - throw new InvalidArgumentException('Invalid "description" in GetPromptResult data.'); - } - - $messages = []; - foreach ($data['messages'] as $message) { - if (!\is_array($message)) { - throw new InvalidArgumentException('Each entry in "messages" of GetPromptResult data must be an array.'); - } - - $messages[] = PromptMessage::fromArray($message); - } - - return new self($messages, $data['description'] ?? null); - } - - /** - * @return array{ - * messages: array, - * description?: string, - * } - */ - public function jsonSerialize(): array - { - $result = [ - 'messages' => $this->messages, - ]; - - if (null !== $this->description) { - $result['description'] = $this->description; - } - - return $result; - } -} diff --git a/src/Schema/Result/InitializeResult.php b/src/Schema/Result/InitializeResult.php deleted file mode 100644 index e80c28ca..00000000 --- a/src/Schema/Result/InitializeResult.php +++ /dev/null @@ -1,109 +0,0 @@ - - */ -class InitializeResult implements ResultInterface -{ - /** - * Create a new InitializeResult. - * - * @param ServerCapabilities $capabilities the capabilities of the server - * @param Implementation $serverInfo information about the server - * @param string|null $instructions Instructions describing how to use the server and its features. This can be used by clients to improve the LLM's understanding of available tools, resources, etc. It can be thought of like a "hint" to the model. For example, this information MAY be added to the system prompt. - * @param array|null $meta optional _meta field - */ - public function __construct( - public readonly ServerCapabilities $capabilities, - public readonly Implementation $serverInfo, - public readonly ?string $instructions = null, - public readonly ?array $meta = null, - public readonly ?ProtocolVersion $protocolVersion = null, - ) { - } - - /** - * @param array{ - * protocolVersion: string, - * capabilities: array, - * serverInfo: array, - * instructions?: string, - * _meta?: array, - * } $data - */ - public static function fromArray(array $data): self - { - if (!isset($data['protocolVersion']) || !\is_string($data['protocolVersion'])) { - throw new InvalidArgumentException('Missing or invalid "protocolVersion".'); - } - if (!isset($data['capabilities']) || !\is_array($data['capabilities'])) { - throw new InvalidArgumentException('Missing or invalid "capabilities".'); - } - if (!isset($data['serverInfo']) || !\is_array($data['serverInfo'])) { - throw new InvalidArgumentException('Missing or invalid "serverInfo".'); - } - - if (isset($data['instructions']) && !\is_string($data['instructions'])) { - throw new InvalidArgumentException('Invalid "instructions" in InitializeResult data.'); - } - if (isset($data['_meta']) && !\is_array($data['_meta'])) { - throw new InvalidArgumentException('Invalid "_meta" in InitializeResult data.'); - } - - return new self( - ServerCapabilities::fromArray($data['capabilities']), - Implementation::fromArray($data['serverInfo']), - $data['instructions'] ?? null, - $data['_meta'] ?? null, - ProtocolVersion::tryFrom($data['protocolVersion']), - ); - } - - /** - * @return array{ - * protocolVersion: string, - * capabilities: ServerCapabilities, - * serverInfo: Implementation, - * instructions?: string, - * _meta?: array, - * } - */ - public function jsonSerialize(): array - { - $protocolVersion = $this->protocolVersion ?? MessageInterface::PROTOCOL_VERSION; - $data = [ - 'protocolVersion' => $protocolVersion->value, - 'capabilities' => $this->capabilities, - 'serverInfo' => $this->serverInfo, - ]; - if (null !== $this->instructions) { - $data['instructions'] = $this->instructions; - } - if (null !== $this->meta) { - $data['_meta'] = $this->meta; - } - - return $data; - } -} diff --git a/src/Schema/Result/ListPromptsResult.php b/src/Schema/Result/ListPromptsResult.php deleted file mode 100644 index fd164c45..00000000 --- a/src/Schema/Result/ListPromptsResult.php +++ /dev/null @@ -1,89 +0,0 @@ - - */ -class ListPromptsResult implements ResultInterface -{ - /** - * @param array $prompts the list of prompt definitions - * @param string|null $nextCursor An opaque token representing the pagination position after the last returned result. - * - * If present, there may be more results available. - */ - public function __construct( - public readonly array $prompts, - public readonly ?string $nextCursor = null, - ) { - } - - /** - * @param array{ - * prompts: array, - * nextCursor?: string, - * } $data - */ - public static function fromArray(array $data): self - { - if (!isset($data['prompts']) || !\is_array($data['prompts'])) { - throw new InvalidArgumentException('Missing or invalid "prompts" array in ListPromptsResult data.'); - } - - if (isset($data['nextCursor']) && !\is_string($data['nextCursor'])) { - throw new InvalidArgumentException('Invalid "nextCursor" in ListPromptsResult data.'); - } - - return new self( - array_map( - static function (mixed $entry): Prompt { - if (!\is_array($entry)) { - throw new InvalidArgumentException('Each entry in "prompts" of ListPromptsResult data must be an array.'); - } - - return Prompt::fromArray($entry); - }, - $data['prompts'], - ), - $data['nextCursor'] ?? null - ); - } - - /** - * @return array{ - * prompts: array, - * nextCursor?: string, - * } - */ - public function jsonSerialize(): array - { - $result = [ - 'prompts' => array_values($this->prompts), - ]; - - if ($this->nextCursor) { - $result['nextCursor'] = $this->nextCursor; - } - - return $result; - } -} diff --git a/src/Schema/Result/ListResourceTemplatesResult.php b/src/Schema/Result/ListResourceTemplatesResult.php deleted file mode 100644 index e0b71146..00000000 --- a/src/Schema/Result/ListResourceTemplatesResult.php +++ /dev/null @@ -1,89 +0,0 @@ - - */ -class ListResourceTemplatesResult implements ResultInterface -{ - /** - * @param array $resourceTemplates the list of resource template definitions - * @param string|null $nextCursor An opaque token representing the pagination position after the last returned result. - * - * If present, there may be more results available. - */ - public function __construct( - public readonly array $resourceTemplates, - public readonly ?string $nextCursor = null, - ) { - } - - /** - * @param array{ - * resourceTemplates: array, - * nextCursor?: string - * } $data - */ - public static function fromArray(array $data): self - { - if (!isset($data['resourceTemplates']) || !\is_array($data['resourceTemplates'])) { - throw new InvalidArgumentException('Missing or invalid "resourceTemplates" array in ListResourceTemplatesResult data.'); - } - - if (isset($data['nextCursor']) && !\is_string($data['nextCursor'])) { - throw new InvalidArgumentException('Invalid "nextCursor" in ListResourceTemplatesResult data.'); - } - - return new self( - array_map( - static function (mixed $entry): ResourceTemplate { - if (!\is_array($entry)) { - throw new InvalidArgumentException('Each entry in "resourceTemplates" of ListResourceTemplatesResult data must be an array.'); - } - - return ResourceTemplate::fromArray($entry); - }, - $data['resourceTemplates'], - ), - $data['nextCursor'] ?? null - ); - } - - /** - * @return array{ - * resourceTemplates: array, - * nextCursor?: string, - * } - */ - public function jsonSerialize(): array - { - $result = [ - 'resourceTemplates' => array_values($this->resourceTemplates), - ]; - - if ($this->nextCursor) { - $result['nextCursor'] = $this->nextCursor; - } - - return $result; - } -} diff --git a/src/Schema/Result/ListResourcesResult.php b/src/Schema/Result/ListResourcesResult.php deleted file mode 100644 index 63066b06..00000000 --- a/src/Schema/Result/ListResourcesResult.php +++ /dev/null @@ -1,89 +0,0 @@ - - */ -class ListResourcesResult implements ResultInterface -{ - /** - * @param array $resources the list of resource definitions - * @param string|null $nextCursor An opaque token representing the pagination position after the last returned result. - * - * If present, there may be more results available. - */ - public function __construct( - public readonly array $resources, - public readonly ?string $nextCursor = null, - ) { - } - - /** - * @param array{ - * resources: array, - * nextCursor?: string, - * } $data - */ - public static function fromArray(array $data): self - { - if (!isset($data['resources']) || !\is_array($data['resources'])) { - throw new InvalidArgumentException('Missing or invalid "resources" array in ListResourcesResult data.'); - } - - if (isset($data['nextCursor']) && !\is_string($data['nextCursor'])) { - throw new InvalidArgumentException('Invalid "nextCursor" in ListResourcesResult data.'); - } - - return new self( - array_map( - static function (mixed $entry): ResourceDefinition { - if (!\is_array($entry)) { - throw new InvalidArgumentException('Each entry in "resources" of ListResourcesResult data must be an array.'); - } - - return ResourceDefinition::fromArray($entry); - }, - $data['resources'], - ), - $data['nextCursor'] ?? null - ); - } - - /** - * @return array{ - * resources: array, - * nextCursor?: string, - * } - */ - public function jsonSerialize(): array - { - $result = [ - 'resources' => array_values($this->resources), - ]; - - if (null !== $this->nextCursor) { - $result['nextCursor'] = $this->nextCursor; - } - - return $result; - } -} diff --git a/src/Schema/Result/ListRootsResult.php b/src/Schema/Result/ListRootsResult.php deleted file mode 100644 index b2088dd0..00000000 --- a/src/Schema/Result/ListRootsResult.php +++ /dev/null @@ -1,81 +0,0 @@ - - */ -class ListRootsResult implements ResultInterface -{ - /** - * @param Root[] $roots an array of root URIs - * @param ?array $meta optional metadata about the result - */ - public function __construct( - public readonly array $roots, - public readonly ?array $meta = null, - ) { - } - - /** - * @param array{ - * roots: array, - * _meta?: ?array - * } $data - */ - public static function fromArray(array $data): self - { - if (!isset($data['roots']) || !\is_array($data['roots'])) { - throw new InvalidArgumentException('Missing or invalid "roots" in ListRootsResult data.'); - } - - $roots = []; - foreach ($data['roots'] as $root) { - if (!\is_array($root)) { - throw new InvalidArgumentException('Invalid root in ListRootsResult data, expected an array.'); - } - - $roots[] = Root::fromArray($root); - } - - $meta = isset($data['_meta']) && \is_array($data['_meta']) ? $data['_meta'] : null; - - return new self($roots, $meta); - } - - /** - * @return array{ - * roots: Root[], - * _meta?: ?array - * } - */ - public function jsonSerialize(): array - { - $result = [ - 'roots' => array_values($this->roots), - ]; - - if (null !== $this->meta) { - $result['_meta'] = $this->meta; - } - - return $result; - } -} diff --git a/src/Schema/Result/ListToolsResult.php b/src/Schema/Result/ListToolsResult.php deleted file mode 100644 index c6ba6c84..00000000 --- a/src/Schema/Result/ListToolsResult.php +++ /dev/null @@ -1,89 +0,0 @@ - - */ -class ListToolsResult implements ResultInterface -{ - /** - * @param array $tools the list of tool definitions - * @param string|null $nextCursor An opaque token representing the pagination position after the last returned result. - * - * If present, there may be more results available. - */ - public function __construct( - public readonly array $tools, - public readonly ?string $nextCursor = null, - ) { - } - - /** - * @param array{ - * tools: array, - * nextCursor?: string, - * } $data - */ - public static function fromArray(array $data): self - { - if (!isset($data['tools']) || !\is_array($data['tools'])) { - throw new InvalidArgumentException('Missing or invalid "tools" array in ListToolsResult data.'); - } - - if (isset($data['nextCursor']) && !\is_string($data['nextCursor'])) { - throw new InvalidArgumentException('Invalid "nextCursor" in ListToolsResult data.'); - } - - return new self( - array_map( - static function (mixed $entry): Tool { - if (!\is_array($entry)) { - throw new InvalidArgumentException('Each entry in "tools" of ListToolsResult data must be an array.'); - } - - return Tool::fromArray($entry); - }, - $data['tools'], - ), - $data['nextCursor'] ?? null - ); - } - - /** - * @return array{ - * tools: array, - * nextCursor?: string, - * } - */ - public function jsonSerialize(): array - { - $result = [ - 'tools' => array_values($this->tools), - ]; - - if ($this->nextCursor) { - $result['nextCursor'] = $this->nextCursor; - } - - return $result; - } -} diff --git a/src/Schema/Result/ReadResourceResult.php b/src/Schema/Result/ReadResourceResult.php deleted file mode 100644 index 7fd80009..00000000 --- a/src/Schema/Result/ReadResourceResult.php +++ /dev/null @@ -1,76 +0,0 @@ - - */ -class ReadResourceResult implements ResultInterface -{ - /** - * Create a new ReadResourceResult. - * - * @param ResourceContents[] $contents The contents of the resource - */ - public function __construct( - public readonly array $contents, - ) { - } - - /** - * @param array{ - * contents: array, - * } $data - */ - public static function fromArray(array $data): self - { - if (!isset($data['contents']) || !\is_array($data['contents'])) { - throw new InvalidArgumentException('Missing or invalid "contents" array in ReadResourceResult data.'); - } - - $contents = []; - foreach ($data['contents'] as $content) { - if (isset($content['text'])) { - $contents[] = TextResourceContents::fromArray($content); - } elseif (isset($content['blob'])) { - $contents[] = BlobResourceContents::fromArray($content); - } else { - throw new InvalidArgumentException('Invalid content type in ReadResourceResult data: '.json_encode($content)); - } - } - - return new self($contents); - } - - /** - * @return array{ - * contents: array, - * } - */ - public function jsonSerialize(): array - { - return [ - 'contents' => $this->contents, - ]; - } -} diff --git a/src/Schema/Root.php b/src/Schema/Root.php deleted file mode 100644 index 3c20e9e7..00000000 --- a/src/Schema/Root.php +++ /dev/null @@ -1,76 +0,0 @@ - - */ -class Root implements \JsonSerializable -{ - private const URI_PATTERN = '/^file:\/\/.*$/'; - - /** - * @param string $uri The URI identifying the root. This *must* start with file:// for now. - * - * This restriction may be relaxed in future versions of the protocol to allow other URI schemes. - * @param string|null $name An optional name for the root. - * - * This can be used to provide a human-readable identifier for the root, which may be useful for - * display purposes or for referencing the root in other parts of the application. - */ - public function __construct( - public readonly string $uri, - public readonly ?string $name = null, - ) { - if (!preg_match(self::URI_PATTERN, $this->uri)) { - throw new InvalidArgumentException(\sprintf('Root URI must start with "file://". Given: "%s".', $this->uri)); - } - } - - /** - * @param RootData $data - */ - public static function fromArray(array $data): self - { - if (empty($data['uri']) || !\is_string($data['uri'])) { - throw new InvalidArgumentException('Invalid or missing "uri" in Root data.'); - } - - if (isset($data['name']) && !\is_string($data['name'])) { - throw new InvalidArgumentException('Invalid "name" in Root data.'); - } - - return new self($data['uri'], $data['name'] ?? null); - } - - /** - * @return RootData - */ - public function jsonSerialize(): array - { - $data = ['uri' => $this->uri]; - if (null !== $this->name) { - $data['name'] = $this->name; - } - - return $data; - } -} diff --git a/src/Schema/ServerCapabilities.php b/src/Schema/ServerCapabilities.php deleted file mode 100644 index 0e47c61d..00000000 --- a/src/Schema/ServerCapabilities.php +++ /dev/null @@ -1,197 +0,0 @@ - - */ -class ServerCapabilities implements \JsonSerializable -{ - /** - * @param ?bool $tools server exposes callable tools - * @param ?bool $toolsListChanged server supports list changed notifications for tools - * @param ?bool $resources server provides readable resources - * @param ?bool $resourcesSubscribe server supports subscribing to changes in the list of resources - * @param ?bool $resourcesListChanged server supports list changed notifications for resources - * @param ?bool $prompts server provides prompts templates - * @param ?bool $promptsListChanged server supports list changed notifications for prompts - * @param ?bool $logging server emits structured log messages - * @param ?bool $completions Server supports argument autocompletion - * @param ?array $experimental experimental, non-standard features that the server supports - * @param ?array $extensions protocol extensions the server supports (e.g. io.modelcontextprotocol/ui) - */ - public function __construct( - public readonly ?bool $tools = true, - public readonly ?bool $toolsListChanged = false, - public readonly ?bool $resources = true, - public readonly ?bool $resourcesSubscribe = false, - public readonly ?bool $resourcesListChanged = false, - public readonly ?bool $prompts = true, - public readonly ?bool $promptsListChanged = false, - public readonly ?bool $logging = false, - public readonly ?bool $completions = false, - public readonly ?array $experimental = null, - public readonly ?array $extensions = null, - ) { - } - - /** - * @param array{ - * logging?: mixed, - * completions?: mixed, - * prompts?: array{listChanged?: bool}|object, - * resources?: array{listChanged?: bool, subscribe?: bool}|object, - * tools?: object|array{listChanged?: bool}, - * experimental?: array, - * extensions?: array, - * } $data - */ - public static function fromArray(array $data): self - { - $loggingEnabled = isset($data['logging']); - $completionsEnabled = isset($data['completions']); - $toolsEnabled = isset($data['tools']); - $promptsEnabled = isset($data['prompts']); - $resourcesEnabled = isset($data['resources']); - - $promptsListChanged = null; - if (isset($data['prompts'])) { - if (\is_array($data['prompts']) && \array_key_exists('listChanged', $data['prompts'])) { - $promptsListChanged = (bool) $data['prompts']['listChanged']; - } elseif (\is_object($data['prompts']) && property_exists($data['prompts'], 'listChanged')) { - $promptsListChanged = (bool) $data['prompts']->listChanged; - } - } - - $resourcesSubscribe = null; - $resourcesListChanged = null; - if (isset($data['resources'])) { - if (\is_array($data['resources']) && \array_key_exists('subscribe', $data['resources'])) { - $resourcesSubscribe = (bool) $data['resources']['subscribe']; - } elseif (\is_object($data['resources']) && property_exists($data['resources'], 'subscribe')) { - $resourcesSubscribe = (bool) $data['resources']->subscribe; - } - if (\is_array($data['resources']) && \array_key_exists('listChanged', $data['resources'])) { - $resourcesListChanged = (bool) $data['resources']['listChanged']; - } elseif (\is_object($data['resources']) && property_exists($data['resources'], 'listChanged')) { - $resourcesListChanged = (bool) $data['resources']->listChanged; - } - } - - $toolsListChanged = null; - if (isset($data['tools'])) { - if (\is_array($data['tools']) && \array_key_exists('listChanged', $data['tools'])) { - $toolsListChanged = (bool) $data['tools']['listChanged']; - } elseif (\is_object($data['tools']) && property_exists($data['tools'], 'listChanged')) { - $toolsListChanged = (bool) $data['tools']->listChanged; - } - } - - return new self( - tools: $toolsEnabled, - toolsListChanged: $toolsListChanged, - resources: $resourcesEnabled, - resourcesSubscribe: $resourcesSubscribe, - resourcesListChanged: $resourcesListChanged, - prompts: $promptsEnabled, - promptsListChanged: $promptsListChanged, - logging: $loggingEnabled, - completions: $completionsEnabled, - experimental: \is_array($data['experimental'] ?? null) ? $data['experimental'] : null, - extensions: \is_array($data['extensions'] ?? null) ? $data['extensions'] : null, - ); - } - - /** - * Returns a copy with the given protocol extensions merged into the existing ones. - * - * Entries in $extensions override existing ones sharing the same id. - * - * @param array> $extensions - */ - public function withExtensions(array $extensions): self - { - return new self( - $this->tools, - $this->toolsListChanged, - $this->resources, - $this->resourcesSubscribe, - $this->resourcesListChanged, - $this->prompts, - $this->promptsListChanged, - $this->logging, - $this->completions, - $this->experimental, - [...$this->extensions ?? [], ...$extensions], - ); - } - - /** - * @return array{ - * logging?: object, - * completions?: object, - * prompts?: object, - * resources?: object, - * tools?: object, - * experimental?: object, - * extensions?: object, - * } - */ - public function jsonSerialize(): array - { - $data = []; - - if ($this->logging) { - $data['logging'] = new \stdClass(); - } - if ($this->completions) { - $data['completions'] = new \stdClass(); - } - - if ($this->prompts || $this->promptsListChanged) { - $data['prompts'] = new \stdClass(); - if ($this->promptsListChanged) { - $data['prompts']->listChanged = $this->promptsListChanged; - } - } - - if ($this->resources || $this->resourcesSubscribe || $this->resourcesListChanged) { - $data['resources'] = new \stdClass(); - if ($this->resourcesSubscribe) { - $data['resources']->subscribe = $this->resourcesSubscribe; - } - if ($this->resourcesListChanged) { - $data['resources']->listChanged = $this->resourcesListChanged; - } - } - - if ($this->tools || $this->toolsListChanged) { - $data['tools'] = new \stdClass(); - if ($this->toolsListChanged) { - $data['tools']->listChanged = $this->toolsListChanged; - } - } - - if ($this->experimental) { - $data['experimental'] = (object) $this->experimental; - } - - if ($this->extensions) { - $data['extensions'] = (object) $this->extensions; - } - - return $data; - } -} diff --git a/src/Schema/Tool.php b/src/Schema/Tool.php deleted file mode 100644 index c4062cfc..00000000 --- a/src/Schema/Tool.php +++ /dev/null @@ -1,283 +0,0 @@ -|\stdClass, - * required: string[]|null - * } - * @phpstan-type ToolOutputSchema array{ - * type: 'object', - * properties?: array|\stdClass, - * required?: string[]|null, - * additionalProperties?: bool|array|\stdClass, - * description?: string - * } - * @phpstan-type ToolData array{ - * name: string, - * title?: string, - * inputSchema: ToolInputSchema, - * description?: string|null, - * annotations?: ToolAnnotationsData, - * icons?: IconData[], - * _meta?: array, - * outputSchema?: ToolOutputSchema - * } - * - * @author Kyrian Obikwelu - */ -class Tool implements \JsonSerializable -{ - /** - * JSON Schema keywords whose value is a single sub-schema. - */ - private const SUB_SCHEMA_KEYWORDS = [ - 'additionalItems', - 'additionalProperties', - 'contains', - 'else', - 'if', - 'not', - 'propertyNames', - 'then', - 'unevaluatedItems', - 'unevaluatedProperties', - ]; - - /** - * JSON Schema keywords whose value maps names to sub-schemas. - */ - private const SUB_SCHEMA_MAP_KEYWORDS = [ - '$defs', - 'definitions', - 'dependentSchemas', - 'patternProperties', - 'properties', - ]; - - /** - * JSON Schema keywords whose value is a list of sub-schemas. - */ - private const SUB_SCHEMA_LIST_KEYWORDS = [ - 'allOf', - 'anyOf', - 'oneOf', - 'prefixItems', - ]; - - /** - * @var ToolInputSchema - */ - public readonly array $inputSchema; - - /** - * @var ToolOutputSchema|null - */ - public readonly ?array $outputSchema; - - /** - * @param string $name the name of the tool - * @param ?string $title Optional human-readable title for display in UI - * @param ToolInputSchema $inputSchema a JSON Schema object (as a PHP array) defining the expected 'arguments' for the tool - * @param ?string $description A human-readable description of the tool. - * This can be used by clients to improve the LLM's understanding of - * available tools. It can be thought of like a "hint" to the model. - * @param ?ToolAnnotations $annotations optional additional tool information - * @param ?Icon[] $icons optional icons representing the tool - * @param ?array $meta Optional metadata - * @param ToolOutputSchema|null $outputSchema optional JSON Schema object (as a PHP array) defining the expected output structure - */ - public function __construct( - public readonly string $name, - public readonly ?string $title, - array $inputSchema, - public readonly ?string $description, - public readonly ?ToolAnnotations $annotations, - public readonly ?array $icons = null, - public readonly ?array $meta = null, - ?array $outputSchema = null, - ) { - if (!isset($inputSchema['type']) || 'object' !== $inputSchema['type']) { - throw new InvalidArgumentException('Tool inputSchema must be a JSON Schema of type "object".'); - } - - // Always normalize here so every construction path emits `{}` for empty - // sub-schemas — not only SchemaGenerator / fromArray. - $this->inputSchema = self::normalizeSchema($inputSchema); - $this->outputSchema = null !== $outputSchema ? self::normalizeSchema($outputSchema) : null; - } - - /** - * @param ToolData $data - */ - public static function fromArray(array $data): self - { - if (empty($data['name']) || !\is_string($data['name'])) { - throw new InvalidArgumentException('Invalid or missing "name" in Tool data.'); - } - if (!isset($data['inputSchema']) || !\is_array($data['inputSchema'])) { - throw new InvalidArgumentException('Invalid or missing "inputSchema" in Tool data.'); - } - if (!isset($data['inputSchema']['type']) || 'object' !== $data['inputSchema']['type']) { - throw new InvalidArgumentException('Tool inputSchema must be of type "object".'); - } - - $outputSchema = null; - if (isset($data['outputSchema']) && \is_array($data['outputSchema'])) { - if (!isset($data['outputSchema']['type']) || 'object' !== $data['outputSchema']['type']) { - throw new InvalidArgumentException('Tool outputSchema must be of type "object".'); - } - $outputSchema = $data['outputSchema']; - } - - return new self( - name: $data['name'], - title: isset($data['title']) && \is_string($data['title']) ? $data['title'] : null, - inputSchema: $data['inputSchema'], - description: isset($data['description']) && \is_string($data['description']) ? $data['description'] : null, - annotations: isset($data['annotations']) && \is_array($data['annotations']) ? ToolAnnotations::fromArray($data['annotations']) : null, - icons: isset($data['icons']) && \is_array($data['icons']) ? Icon::listFromArray($data['icons'], 'Tool') : null, - meta: isset($data['_meta']) && \is_array($data['_meta']) ? $data['_meta'] : null, - outputSchema: $outputSchema, - ); - } - - /** - * @return array{ - * name: string, - * title?: string, - * inputSchema: ToolInputSchema, - * description?: string, - * annotations?: ToolAnnotations, - * icons?: Icon[], - * _meta?: array, - * outputSchema?: ToolOutputSchema - * } - */ - public function jsonSerialize(): array - { - $data = ['name' => $this->name]; - if (null !== $this->title) { - $data['title'] = $this->title; - } - $data['inputSchema'] = $this->inputSchema; - if (null !== $this->description) { - $data['description'] = $this->description; - } - if (null !== $this->annotations) { - $data['annotations'] = $this->annotations; - } - if (null !== $this->icons) { - $data['icons'] = $this->icons; - } - if (null !== $this->meta) { - $data['_meta'] = $this->meta; - } - if (null !== $this->outputSchema) { - $data['outputSchema'] = $this->outputSchema; - } - - return $data; - } - - /** - * Normalize a JSON Schema so that empty sub-schemas JSON-encode as `{}` rather than `[]`. - * - * Once JSON is decoded into associative arrays, PHP cannot tell the empty object `{}` - * from the empty array `[]` — both are `[]`. Re-encoding then produces `[]`, which is - * invalid wherever a schema is expected (`properties`, `items`, `additionalProperties`, - * …), and strict clients reject it. Every empty sub-schema is therefore replaced with a - * `\stdClass` before serialization. - * - * The walk is recursive and covers the schema keywords of draft-07 through 2020-12, so - * nested object parameters, `$defs`, combinators, and `outputSchema` are all covered — - * not only the top-level `properties` map. - * - * @param array $schema - * - * @return array - */ - private static function normalizeSchema(array $schema): array - { - foreach (self::SUB_SCHEMA_KEYWORDS as $keyword) { - if (isset($schema[$keyword]) && \is_array($schema[$keyword])) { - $schema[$keyword] = self::normalizeSubSchema($schema[$keyword]); - } - } - - foreach (self::SUB_SCHEMA_MAP_KEYWORDS as $keyword) { - if (!isset($schema[$keyword]) || !\is_array($schema[$keyword])) { - continue; - } - - if ([] === $schema[$keyword]) { - $schema[$keyword] = new \stdClass(); - continue; - } - - foreach ($schema[$keyword] as $name => $subSchema) { - if (\is_array($subSchema)) { - $schema[$keyword][$name] = self::normalizeSubSchema($subSchema); - } - } - } - - foreach (self::SUB_SCHEMA_LIST_KEYWORDS as $keyword) { - if (!isset($schema[$keyword]) || !\is_array($schema[$keyword])) { - continue; - } - - // An empty list stays a list — `allOf: []` is already valid JSON. - foreach ($schema[$keyword] as $index => $subSchema) { - if (\is_array($subSchema)) { - $schema[$keyword][$index] = self::normalizeSubSchema($subSchema); - } - } - } - - if (isset($schema['items']) && \is_array($schema['items'])) { - // `items` is a single sub-schema, or a list of them in draft-07 tuple form. - // An empty array is read as the empty schema `{}` — what an `items: {}` from - // SchemaGenerator decodes to — rather than as an empty tuple. - if ([] !== $schema['items'] && array_is_list($schema['items'])) { - foreach ($schema['items'] as $index => $itemSchema) { - if (\is_array($itemSchema)) { - $schema['items'][$index] = self::normalizeSubSchema($itemSchema); - } - } - } else { - $schema['items'] = self::normalizeSubSchema($schema['items']); - } - } - - return $schema; - } - - /** - * @param array $schema - * - * @return array|\stdClass - */ - private static function normalizeSubSchema(array $schema): array|\stdClass - { - return [] === $schema ? new \stdClass() : self::normalizeSchema($schema); - } -} diff --git a/src/Schema/ToolAnnotations.php b/src/Schema/ToolAnnotations.php deleted file mode 100644 index c4f27746..00000000 --- a/src/Schema/ToolAnnotations.php +++ /dev/null @@ -1,96 +0,0 @@ - - */ -class ToolAnnotations implements \JsonSerializable -{ - /** - * @param ?string $title a human-readable title for the tool — deprecated for display in favor of `Mcp\Schema\Tool::$title` per MCP spec revision 2025-06-18; retained for backward compatibility - * @param ?bool $readOnlyHint if true, the tool does not modify its environment - * @param ?bool $destructiveHint If true, the tool may perform destructive updates to its environment. If false, the tool performs only additive updates. - * @param ?bool $idempotentHint If true, calling the tool repeatedly with the same arguments will have no additional effect on the its environment. (This property is meaningful only when `readOnlyHint == false`) - * @param ?bool $openWorldHint If true, this tool may interact with an "open world" of external entities. If false, the tool's domain of interaction is closed. For example, the world of a web search tool is open, whereas that of a memory tool is not. - */ - public function __construct( - public readonly ?string $title = null, - public readonly ?bool $readOnlyHint = null, - public readonly ?bool $destructiveHint = null, - public readonly ?bool $idempotentHint = null, - public readonly ?bool $openWorldHint = null, - ) { - } - - /** - * @param ToolAnnotationsData $data - */ - public static function fromArray(array $data): self - { - if (isset($data['title']) && !\is_string($data['title'])) { - throw new InvalidArgumentException('Invalid "title" in ToolAnnotations data.'); - } - - foreach (['readOnlyHint', 'destructiveHint', 'idempotentHint', 'openWorldHint'] as $hint) { - if (isset($data[$hint]) && !\is_bool($data[$hint])) { - throw new InvalidArgumentException(\sprintf('Invalid "%s" in ToolAnnotations data; expected a boolean.', $hint)); - } - } - - return new self( - $data['title'] ?? null, - $data['readOnlyHint'] ?? null, - $data['destructiveHint'] ?? null, - $data['idempotentHint'] ?? null, - $data['openWorldHint'] ?? null - ); - } - - /** - * @return ToolAnnotationsData - */ - public function jsonSerialize(): array - { - $data = []; - if (null !== $this->title) { - $data['title'] = $this->title; - } - if (null !== $this->readOnlyHint) { - $data['readOnlyHint'] = $this->readOnlyHint; - } - if (null !== $this->destructiveHint) { - $data['destructiveHint'] = $this->destructiveHint; - } - if (null !== $this->idempotentHint) { - $data['idempotentHint'] = $this->idempotentHint; - } - if (null !== $this->openWorldHint) { - $data['openWorldHint'] = $this->openWorldHint; - } - - return $data; - } -} diff --git a/src/Schema/ToolChoice.php b/src/Schema/ToolChoice.php deleted file mode 100644 index 4b55bac3..00000000 --- a/src/Schema/ToolChoice.php +++ /dev/null @@ -1,51 +0,0 @@ - $this->mode->value]; - } -} diff --git a/src/Server.php b/src/Server.php deleted file mode 100644 index 8657610a..00000000 --- a/src/Server.php +++ /dev/null @@ -1,58 +0,0 @@ - - * @author Kyrian Obikwelu - */ -final class Server -{ - public function __construct( - private readonly Protocol $protocol, - private readonly LoggerInterface $logger = new NullLogger(), - ) { - } - - public static function builder(): Builder - { - return new Builder(); - } - - /** - * @template TResult - * - * @param TransportInterface $transport - * - * @return TResult - */ - public function run(TransportInterface $transport): mixed - { - $transport->initialize(); - - $this->protocol->connect($transport); - - $this->logger->info('Running server...'); - - try { - return $transport->listen(); - } finally { - $transport->close(); - } - } -} diff --git a/src/Server/Builder.php b/src/Server/Builder.php deleted file mode 100644 index fbce3d9b..00000000 --- a/src/Server/Builder.php +++ /dev/null @@ -1,816 +0,0 @@ - - */ -final class Builder -{ - private ?Implementation $serverInfo = null; - - private RegistryInterface $registry; - - private ?SubscriptionManagerInterface $subscriptionManager = null; - - private ?LoggerInterface $logger = null; - - private ?CacheInterface $discoveryCache = null; - - private ?EventDispatcherInterface $eventDispatcher = null; - - private ?ContainerInterface $container = null; - - private ?SchemaGeneratorInterface $schemaGenerator = null; - - private ?ReferenceHandlerInterface $referenceHandler = null; - - private ?DiscovererInterface $discoverer = null; - - private ?SessionManagerInterface $sessionManager = null; - - private ?SessionStoreInterface $sessionStore = null; - - private int $gcProbability = 1; - - private int $gcDivisor = 100; - - private int $paginationLimit = 50; - - private ?string $instructions = null; - - private ?ProtocolVersion $protocolVersion = null; - - /** - * @var array> - */ - private array $requestHandlers = []; - - /** - * @var array - */ - private array $notificationHandlers = []; - - /** - * @var array{ - * handler: Handler, - * name: ?string, - * title: ?string, - * description: ?string, - * annotations: ?ToolAnnotations, - * inputSchema: ?array, - * icons: ?Icon[], - * meta: ?array, - * outputSchema: ?array, - * }[] - */ - private array $tools = []; - - /** - * @var array{ - * handler: Handler, - * uri: string, - * name: ?string, - * title: ?string, - * description: ?string, - * mimeType: ?string, - * size: int|null, - * annotations: ?Annotations, - * icons: ?Icon[], - * meta: ?array - * }[] - */ - private array $resources = []; - - /** - * @var array{ - * handler: Handler, - * uriTemplate: string, - * name: ?string, - * title: ?string, - * description: ?string, - * mimeType: ?string, - * annotations: ?Annotations, - * meta: ?array - * }[] - */ - private array $resourceTemplates = []; - - /** - * @var array{ - * handler: Handler, - * name: ?string, - * title: ?string, - * description: ?string, - * icons: ?Icon[], - * meta: ?array - * }[] - */ - private array $prompts = []; - - /** - * @var list - */ - private array $explicitTools = []; - - /** - * @var list - */ - private array $explicitResources = []; - - /** - * @var list}> - */ - private array $explicitResourceTemplates = []; - - /** - * @var list}> - */ - private array $explicitPrompts = []; - - private ?string $discoveryBasePath = null; - - /** - * @var string[] - */ - private array $discoveryScanDirs = []; - - /** - * @var array|string[] - */ - private array $discoveryExcludeDirs = []; - - /** - * @var string[]|null - */ - private ?array $discoveryNamePatterns = null; - - private ?ServerCapabilities $serverCapabilities = null; - - /** - * @var array> - */ - private array $extensions = []; - - /** - * @var LoaderInterface[] - */ - private array $loaders = []; - - private bool $hasCustomRegistry = false; - - private bool $lazyLoading = true; - - /** - * Sets the server's identity. Required. - * - * @param ?Icon[] $icons - */ - public function setServerInfo( - string $name, - string $version, - ?string $description = null, - ?array $icons = null, - ?string $websiteUrl = null, - ): self { - $this->serverInfo = new Implementation(trim($name), trim($version), $description, $icons, $websiteUrl); - - return $this; - } - - /** - * Configures the server's pagination limit. - */ - public function setPaginationLimit(int $paginationLimit): self - { - $this->paginationLimit = $paginationLimit; - - return $this; - } - - /** - * Configures the instructions describing how to use the server and its features. - * - * This can be used by clients to improve the LLM's understanding of available tools, resources, - * etc. It can be thought of like a "hint" to the model. For example, this information MAY - * be added to the system prompt. - */ - public function setInstructions(?string $instructions): self - { - $this->instructions = $instructions; - - return $this; - } - - /** - * Explicitly set server capabilities. If set, this overrides automatic detection. - */ - public function setCapabilities(ServerCapabilities $serverCapabilities): self - { - $this->serverCapabilities = $serverCapabilities; - - return $this; - } - - /** - * Enable one or more MCP protocol extensions, announced to clients under - * `capabilities.extensions` during the initialize handshake. - * - * @throws LogicException if the same extension is enabled more than once - */ - public function enableExtension(ServerExtensionInterface ...$extensions): self - { - foreach ($extensions as $extension) { - $id = $extension->getId(); - - if (isset($this->extensions[$id])) { - throw new LogicException(\sprintf('Extension "%s" is already enabled.', $id)); - } - - $this->extensions[$id] = $extension->getCapabilities(); - } - - return $this; - } - - /** - * Register a single custom method handler. - * - * @param RequestHandlerInterface $handler - */ - public function addRequestHandler(RequestHandlerInterface $handler): self - { - $this->requestHandlers[] = $handler; - - return $this; - } - - /** - * Register multiple custom method handlers. - * - * @param iterable> $handlers - */ - public function addRequestHandlers(iterable $handlers): self - { - foreach ($handlers as $handler) { - $this->requestHandlers[] = $handler; - } - - return $this; - } - - /** - * Register a single custom notification handler. - */ - public function addNotificationHandler(NotificationHandlerInterface $handler): self - { - $this->notificationHandlers[] = $handler; - - return $this; - } - - /** - * Register multiple custom notification handlers. - * - * @param iterable $handlers - */ - public function addNotificationHandlers(iterable $handlers): self - { - foreach ($handlers as $handler) { - $this->notificationHandlers[] = $handler; - } - - return $this; - } - - public function setRegistry(RegistryInterface $registry): self - { - $this->registry = $registry; - $this->hasCustomRegistry = true; - - return $this; - } - - /** - * Controls when configured loaders (manual elements, discovery, custom loaders) run. - * - * Lazy (the default) defers loading to the first registry read so a persistent runtime does not - * freeze the registry to a source not yet ready at build time. Disable to load eagerly at build. - * A registry supplied via setRegistry() is always loaded eagerly. - */ - public function setLazyLoading(bool $lazyLoading = true): self - { - $this->lazyLoading = $lazyLoading; - - return $this; - } - - /** - * Provides a PSR-3 logger instance. Defaults to NullLogger. - */ - public function setLogger(LoggerInterface $logger): self - { - $this->logger = $logger; - - return $this; - } - - public function setEventDispatcher(EventDispatcherInterface $eventDispatcher): self - { - $this->eventDispatcher = $eventDispatcher; - - return $this; - } - - /** - * Provides a PSR-11 DI container, primarily for resolving user-defined handler classes. - * Defaults to a basic internal container. - */ - public function setContainer(ContainerInterface $container): self - { - $this->container = $container; - - return $this; - } - - public function setSchemaGenerator(SchemaGeneratorInterface $schemaGenerator): self - { - $this->schemaGenerator = $schemaGenerator; - - return $this; - } - - public function setReferenceHandler(ReferenceHandlerInterface $referenceHandler): self - { - $this->referenceHandler = $referenceHandler; - - return $this; - } - - public function setDiscoverer(DiscovererInterface $discoverer): self - { - $this->discoverer = $discoverer; - - return $this; - } - - public function setResourceSubscriptionManager(SubscriptionManagerInterface $subscriptionManager): self - { - $this->subscriptionManager = $subscriptionManager; - - return $this; - } - - /** - * Configures the session layer. - * - * @param int $gcProbability The numerator of the GC probability fraction (like PHP's session.gc_probability). Set to 0 to disable GC. - * @param int $gcDivisor The denominator of the GC probability fraction (like PHP's session.gc_divisor). Probability = gcProbability/gcDivisor. - */ - public function setSession( - ?SessionStoreInterface $sessionStore = null, - ?SessionManagerInterface $sessionManager = null, - int $gcProbability = 1, - int $gcDivisor = 100, - ): self { - $this->sessionStore = $sessionStore; - $this->sessionManager = $sessionManager; - $this->gcProbability = $gcProbability; - $this->gcDivisor = $gcDivisor; - - if (null !== $sessionManager && null !== $sessionStore) { - throw new InvalidArgumentException('Cannot set both SessionStore and SessionManager. Set only one or the other.'); - } - - return $this; - } - - /** - * @param string[] $scanDirs - * @param string[] $excludeDirs - * @param string[] $namePatterns - */ - public function setDiscovery( - string $basePath, - array $scanDirs = ['.', 'src'], - array $excludeDirs = [], - ?CacheInterface $cache = null, - array $namePatterns = DiscovererInterface::DEFAULT_NAME_PATERNS, - ): self { - $this->discoveryBasePath = $basePath; - $this->discoveryScanDirs = $scanDirs; - $this->discoveryExcludeDirs = $excludeDirs; - $this->discoveryCache = $cache; - $this->discoveryNamePatterns = $namePatterns; - - return $this; - } - - public function setProtocolVersion(ProtocolVersion $protocolVersion): self - { - $this->protocolVersion = $protocolVersion; - - return $this; - } - - /** - * Manually registers a tool handler. - * - * @param Handler $handler - * @param ?string $title Optional human-readable title for display in UI - * @param array|null $inputSchema - * @param ?Icon[] $icons - * @param array|null $meta - * @param array|null $outputSchema - */ - public function addTool( - callable|array|string $handler, - ?string $name = null, - ?string $title = null, - ?string $description = null, - ?ToolAnnotations $annotations = null, - ?array $inputSchema = null, - ?array $icons = null, - ?array $meta = null, - ?array $outputSchema = null, - ): self { - $this->tools[] = compact( - 'handler', - 'name', - 'title', - 'description', - 'annotations', - 'inputSchema', - 'icons', - 'meta', - 'outputSchema', - ); - - return $this; - } - - /** - * Manually registers a resource handler. - * - * @param Handler $handler - * @param ?string $title Optional human-readable title for display in UI - * @param ?Icon[] $icons - * @param array|null $meta - */ - public function addResource( - \Closure|array|string $handler, - string $uri, - ?string $name = null, - ?string $title = null, - ?string $description = null, - ?string $mimeType = null, - ?int $size = null, - ?Annotations $annotations = null, - ?array $icons = null, - ?array $meta = null, - ): self { - $this->resources[] = compact( - 'handler', - 'uri', - 'name', - 'title', - 'description', - 'mimeType', - 'size', - 'annotations', - 'icons', - 'meta', - ); - - return $this; - } - - /** - * Manually registers a resource template handler. - * - * @param Handler $handler - * @param ?string $title Optional human-readable title for display in UI - * @param array|null $meta - */ - public function addResourceTemplate( - \Closure|array|string $handler, - string $uriTemplate, - ?string $name = null, - ?string $title = null, - ?string $description = null, - ?string $mimeType = null, - ?Annotations $annotations = null, - ?array $meta = null, - ): self { - $this->resourceTemplates[] = compact( - 'handler', - 'uriTemplate', - 'name', - 'title', - 'description', - 'mimeType', - 'annotations', - 'meta', - ); - - return $this; - } - - /** - * Manually registers a prompt handler. - * - * @param Handler $handler - * @param ?Icon[] $icons - * @param array|null $meta - */ - public function addPrompt( - \Closure|array|string $handler, - ?string $name = null, - ?string $title = null, - ?string $description = null, - ?array $icons = null, - ?array $meta = null, - ): self { - $this->prompts[] = compact('handler', 'name', 'title', 'description', 'icons', 'meta'); - - return $this; - } - - /** - * Registers an element using an explicit schema value object paired with a handler interface. - * - * Use this entry point when an element's name, schema, or description is only known at - * runtime (e.g. config-driven integrations). For statically-known elements, prefer - * `addTool/addResource/addResourceTemplate/addPrompt`, which can derive metadata from - * reflection of the handler. - * - * Mismatched pairings (e.g. a `Tool` with a `PromptHandlerInterface`) raise - * `Mcp\Exception\InvalidArgumentException`. Completion providers are only supported on - * `Prompt` and `ResourceTemplate` definitions; supplying them with `Tool` or - * `ResourceDefinition` raises the same exception. - * - * @param array $completionProviders Keyed by argument/variable name - */ - public function add( - Tool|ResourceDefinition|ResourceTemplate|Prompt $definition, - ElementHandlerInterface $handler, - array $completionProviders = [], - ): self { - if ([] !== $completionProviders && ($definition instanceof Tool || $definition instanceof ResourceDefinition)) { - throw new InvalidArgumentException(\sprintf('Completion providers are only supported on Prompt and ResourceTemplate definitions, got %s.', $definition::class)); - } - - match (true) { - $definition instanceof Tool && $handler instanceof ToolHandlerInterface => $this->explicitTools[] = ['definition' => $definition, 'handler' => $handler], - $definition instanceof ResourceDefinition && $handler instanceof ResourceHandlerInterface => $this->explicitResources[] = ['definition' => $definition, 'handler' => $handler], - $definition instanceof ResourceTemplate && $handler instanceof ResourceTemplateHandlerInterface => $this->explicitResourceTemplates[] = ['definition' => $definition, 'handler' => $handler, 'completionProviders' => $completionProviders], - $definition instanceof Prompt && $handler instanceof PromptHandlerInterface => $this->explicitPrompts[] = ['definition' => $definition, 'handler' => $handler, 'completionProviders' => $completionProviders], - default => throw new InvalidArgumentException(\sprintf('%s definition cannot be paired with %s; expected the matching handler interface.', $definition::class, $handler::class)), - }; - - return $this; - } - - /** - * Register a single custom loader. - */ - public function addLoader(LoaderInterface $loader): self - { - $this->loaders[] = $loader; - - return $this; - } - - /** - * @param iterable $loaders - */ - public function addLoaders(iterable $loaders): self - { - foreach ($loaders as $loader) { - $this->loaders[] = $loader; - } - - return $this; - } - - /** - * Builds the fully configured Server instance. - */ - public function build(): Server - { - $logger = $this->logger ?? new NullLogger(); - $container = $this->container ?? new Container(); - $subscriptionManager = $this->subscriptionManager ?? new SessionSubscriptionManager($logger); - $sessionManager = $this->sessionManager ?? new SessionManager( - $this->sessionStore ?? new InMemorySessionStore(), - $logger, - $this->gcProbability, - $this->gcDivisor, - ); - - // ExplicitElementLoader and ReflectedElementLoader run before DiscoveryLoader so manual entries are seen first; - // DiscoveryLoader's identity check then preserves them against same-name discovered entries. - $loaders = [ - ...$this->loaders, - new ExplicitElementLoader( - $this->explicitTools, - $this->explicitResources, - $this->explicitResourceTemplates, - $this->explicitPrompts, - ), - new ReflectedElementLoader($this->tools, $this->resources, $this->resourceTemplates, $this->prompts, $logger, $this->schemaGenerator), - ]; - - if (null !== $this->discoveryBasePath) { - if (null !== $this->discoverer || class_exists(Finder::class)) { - $discoverer = $this->discoverer ?? $this->createDiscoverer($logger); - $loaders[] = new DiscoveryLoader($this->discoveryBasePath, $this->discoveryScanDirs, $this->discoveryExcludeDirs, $discoverer, $this->discoveryNamePatterns, $logger); - } else { - $logger->warning('File-based discovery requires symfony/finder. Skipping automatic discovery. Run: composer require symfony/finder'); - } - } - - $chainLoader = new ChainLoader($loaders); - - if ($this->hasCustomRegistry) { - // Builder can't inject the loader into an already-constructed instance, so load it eagerly. - $registry = $this->registry; - $chainLoader->load($registry); - $eagerlyLoaded = true; - } else { - $registry = new Registry($this->eventDispatcher, $logger, loader: $chainLoader); - if (!$this->lazyLoading) { - $registry->load(); - } - $eagerlyLoaded = !$this->lazyLoading; - } - - $messageFactory = MessageFactory::make(); - - $capabilities = $this->serverCapabilities ?? $this->detectCapabilities($registry, $eagerlyLoaded); - - // Extensions enabled via enableExtension() are folded into caller-supplied - // capabilities too, so setCapabilities() does not silently drop them. - if (null !== $this->serverCapabilities && [] !== $this->extensions) { - $capabilities = $capabilities->withExtensions($this->extensions); - } - - if (null !== $this->protocolVersion && $this->protocolVersion->isModern()) { - $logger->warning('Configured protocol version cannot be reached through the "initialize" handshake, negotiating the handshake revisions instead.', [ - 'configured' => $this->protocolVersion->value, - 'negotiable' => array_map(static fn (ProtocolVersion $v): string => $v->value, ProtocolVersion::handshakeVersions()), - ]); - } - - $serverInfo = $this->serverInfo ?? new Implementation(); - $configuration = new Configuration($serverInfo, $capabilities, $this->paginationLimit, $this->instructions, $this->protocolVersion); - $referenceHandler = $this->referenceHandler ?? new ReferenceHandler($container); - - $requestHandlers = array_merge($this->requestHandlers, [ - new Handler\Request\CallToolHandler($registry, $referenceHandler, $logger), - new Handler\Request\CompletionCompleteHandler($registry, $container), - new Handler\Request\GetPromptHandler($registry, $referenceHandler, $logger), - new Handler\Request\InitializeHandler($configuration), - new Handler\Request\ListPromptsHandler($registry, $this->paginationLimit), - new Handler\Request\ListResourcesHandler($registry, $this->paginationLimit), - new Handler\Request\ListResourceTemplatesHandler($registry, $this->paginationLimit), - new Handler\Request\ListToolsHandler($registry, $this->paginationLimit), - new Handler\Request\PingHandler(), - new Handler\Request\ReadResourceHandler($registry, $referenceHandler, $logger), - new Handler\Request\ResourceSubscribeHandler($registry, $subscriptionManager, $logger), - new Handler\Request\ResourceUnsubscribeHandler($registry, $subscriptionManager, $logger), - new Handler\Request\SetLogLevelHandler(), - ]); - - $notificationHandlers = array_merge($this->notificationHandlers, [ - new Handler\Notification\InitializedHandler(), - ]); - - $protocol = new Protocol( - requestHandlers: $requestHandlers, - notificationHandlers: $notificationHandlers, - messageFactory: $messageFactory, - sessionManager: $sessionManager, - logger: $logger, - eventDispatcher: $this->eventDispatcher, - ); - - return new Server($protocol, $logger); - } - - /** - * When loaded, capabilities are read from the registry. When deferred, reading it would force - * the load, so they are advertised from the configured sources instead — opaque sources (custom - * loaders, discovery) advertise all kinds, and over-advertising is harmless per MCP semantics. - */ - private function detectCapabilities(RegistryInterface $registry, bool $eagerlyLoaded): ServerCapabilities - { - $listChanged = $this->eventDispatcher instanceof EventDispatcherInterface; - - if ($eagerlyLoaded) { - $hasResources = $registry->hasResources() || $registry->hasResourceTemplates(); - - return new ServerCapabilities( - tools: $registry->hasTools(), - toolsListChanged: $listChanged, - resources: $hasResources, - resourcesSubscribe: $hasResources, - resourcesListChanged: $listChanged, - prompts: $registry->hasPrompts(), - promptsListChanged: $listChanged, - logging: true, - completions: true, - extensions: $this->extensions ?: null, - ); - } - - $hasOpaqueSources = [] !== $this->loaders || null !== $this->discoveryBasePath; - $hasResources = [] !== $this->resources || [] !== $this->explicitResources || [] !== $this->resourceTemplates || [] !== $this->explicitResourceTemplates || $hasOpaqueSources; - - return new ServerCapabilities( - tools: [] !== $this->tools || [] !== $this->explicitTools || $hasOpaqueSources, - toolsListChanged: $listChanged, - resources: $hasResources, - resourcesSubscribe: $hasResources, - resourcesListChanged: $listChanged, - prompts: [] !== $this->prompts || [] !== $this->explicitPrompts || $hasOpaqueSources, - promptsListChanged: $listChanged, - logging: true, - completions: true, - extensions: $this->extensions ?: null, - ); - } - - private function createDiscoverer(LoggerInterface $logger): DiscovererInterface - { - $discoverer = new Discoverer($logger, null, $this->schemaGenerator); - - if (null !== $this->discoveryCache) { - return new CachedDiscoverer($discoverer, $this->discoveryCache, $logger); - } - - return $discoverer; - } -} diff --git a/src/Server/ClientGateway.php b/src/Server/ClientGateway.php deleted file mode 100644 index 445c9e06..00000000 --- a/src/Server/ClientGateway.php +++ /dev/null @@ -1,351 +0,0 @@ -getClientGateway(); - * // Send progress notification - * $client->notify(new ProgressNotification("Starting analysis...")); - * - * // Request LLM sampling from client - * $response = $client->request(new SamplingRequest($text)); - * - * return $response->content->text; - * } - * ``` - * - * @phpstan-type SampleOptions array{ - * preferences?: ModelPreferences, - * systemPrompt?: string, - * temperature?: float, - * includeContext?: SamplingContext, - * stopSequences?: string[], - * metadata?: array, - * tools?: Tool[], - * toolChoice?: ToolChoice, - * } - * - * @author Kyrian Obikwelu - */ -class ClientGateway -{ - public function __construct( - private readonly SessionInterface $session, - ) { - } - - /** - * Send a notification to the client (fire and forget). - * - * This suspends the Fiber to let the transport flush the notification via SSE, - * then immediately resumes execution. - */ - public function notify(Notification $notification): void - { - \Fiber::suspend([ - 'type' => 'notification', - 'notification' => $notification, - 'session_id' => $this->session->getId()->toRfc4122(), - ]); - } - - /** - * Convenience method to send a logging notification to the client. - */ - public function log(LoggingLevel $level, mixed $data, ?string $logger = null): void - { - $this->notify(new LoggingMessageNotification($level, $data, $logger)); - } - - /** - * Convenience method to send a progress notification to the client. - */ - public function progress(float $progress, ?float $total = null, ?string $message = null): void - { - $meta = $this->session->get(Protocol::SESSION_ACTIVE_REQUEST_META, []); - $progressToken = $meta['progressToken'] ?? null; - - if (null === $progressToken) { - // Per the spec the client never asked for progress, so just bail. - return; - } - - $this->notify(new ProgressNotification($progressToken, $progress, $total, $message)); - } - - /** - * Convenience method for LLM sampling requests. - * - * @param SamplingMessage[]|TextContent|AudioContent|ImageContent|string $message The message for the LLM - * @param int $maxTokens Maximum tokens to generate - * @param int $timeout The timeout in seconds - * @param SampleOptions $options Additional sampling options (temperature, etc.) - * Context values other than `none` require the client's - * sampling.context capability; tools and toolChoice require - * the client's sampling.tools capability. - * - * @return CreateSamplingMessageResult The sampling response - * - * @throws ClientException if the client request results in an error message - */ - public function sample(array|Content|string $message, int $maxTokens = 1000, int $timeout = 120, array $options = []): CreateSamplingMessageResult - { - $preferences = $options['preferences'] ?? null; - if (null !== $preferences && !$preferences instanceof ModelPreferences) { - throw new InvalidArgumentException('The "preferences" option must be an array or an instance of ModelPreferences.'); - } - - if (\is_string($message)) { - $message = new TextContent($message); - } - if (\is_object($message) && \in_array($message::class, [TextContent::class, AudioContent::class, ImageContent::class], true)) { - $message = [new SamplingMessage(Role::User, $message)]; - } - - $request = new CreateSamplingMessageRequest( - messages: $message, - maxTokens: $maxTokens, - preferences: $preferences, - systemPrompt: $options['systemPrompt'] ?? null, - includeContext: $options['includeContext'] ?? null, - temperature: $options['temperature'] ?? null, - stopSequences: $options['stopSequences'] ?? null, - metadata: $options['metadata'] ?? null, - tools: $options['tools'] ?? null, - toolChoice: $options['toolChoice'] ?? null, - ); - - // Fail here rather than letting the client reject the request with -32602. - $request->validateToolFlow(); - - $response = $this->request($request, $timeout); - - if ($response instanceof Error) { - throw new ClientException($response); - } - - return CreateSamplingMessageResult::fromArray($response->result); - } - - /** - * Convenience method for elicitation requests. - * - * Requests additional information from the user via the client. The user can - * accept (providing the requested data), decline, or cancel the request. - * - * @param string $message A human-readable message describing what information is needed - * @param ElicitationSchema $requestedSchema The schema defining the fields to elicit from the user - * @param int $timeout The timeout in seconds - * - * @return ElicitResult The elicitation response containing the user's action and any provided content - * - * @throws ClientException if the client request results in an error message - */ - public function elicit(string $message, ElicitationSchema $requestedSchema, int $timeout = 120): ElicitResult - { - $request = new ElicitRequest($message, $requestedSchema); - - $response = $this->request($request, $timeout); - - if ($response instanceof Error) { - throw new ClientException($response); - } - - return ElicitResult::fromArray($response->result); - } - - /** - * Request the list of filesystem roots exposed by the client. - * - * Roots are the client's "workspace folders" — the directories or files the - * server is allowed to operate on. The client answers the roots/list request - * with a list of file:// URIs. - * - * @param int $timeout The timeout in seconds - * - * @return ListRootsResult The roots exposed by the client - * - * @throws ClientException if the client request results in an error message - */ - public function listRoots(int $timeout = 120): ListRootsResult - { - $request = new ListRootsRequest(); - - $response = $this->request($request, $timeout); - - if ($response instanceof Error) { - throw new ClientException($response); - } - - return ListRootsResult::fromArray($response->result); - } - - /** - * Check if the connected client supports roots. - * - * Roots allow servers to ask the client for the set of directories or files - * it is permitted to operate on. This method checks the client's advertised - * capabilities to determine if roots/list requests are supported. - * - * @return bool True if the client supports roots, false otherwise - */ - public function supportsRoots(): bool - { - $capabilities = (array) $this->session->get('client_capabilities', []); - - // MCP spec: capability presence indicates support (value is typically {} or []) - return \array_key_exists('roots', $capabilities); - } - - /** - * Check if the connected client supports elicitation. - * - * Elicitation allows servers to request additional information from users - * during tool execution. This method checks the client's advertised capabilities - * to determine if elicitation/create requests are supported. - * - * @return bool True if the client supports elicitation, false otherwise - */ - public function supportsElicitation(): bool - { - $capabilities = (array) $this->session->get('client_capabilities', []); - - // MCP spec: capability presence indicates support (value is typically {} or []) - return \array_key_exists('elicitation', $capabilities); - } - - /** - * Check if the connected client supports sampling. - * - * Sampling lets a server borrow the client's model during tool execution. - * This method checks the client's advertised capabilities to determine if - * sampling/createMessage requests are supported. - * - * @return bool True if the client supports sampling, false otherwise - */ - public function supportsSampling(): bool - { - $capabilities = (array) $this->session->get('client_capabilities', []); - - // MCP spec: capability presence indicates support (value is typically {} or []) - return \array_key_exists('sampling', $capabilities); - } - - /** - * Check if the connected client supports tools during sampling. - * - * Per the spec a server MUST NOT put `tools` or `toolChoice` on a - * `sampling/createMessage` request unless the client advertised - * `sampling.tools`, so check this before passing either option to - * {@see self::sample()}. - * - * @return bool True if the client supports tool-enabled sampling, false otherwise - */ - public function supportsSamplingTools(): bool - { - return $this->hasSamplingSubCapability('tools'); - } - - /** - * Check if the connected client supports context inclusion during sampling. - * - * The `includeContext` values other than `none` are soft-deprecated and should - * only be sent when the client advertised `sampling.context`. - * - * @return bool True if the client supports sampling context, false otherwise - */ - public function supportsSamplingContext(): bool - { - return $this->hasSamplingSubCapability('context'); - } - - private function hasSamplingSubCapability(string $name): bool - { - $capabilities = (array) $this->session->get('client_capabilities', []); - $sampling = $capabilities['sampling'] ?? null; - - if (!\is_array($sampling) && !\is_object($sampling)) { - return false; - } - - // MCP spec: capability presence indicates support (value is typically {} or []) - return \array_key_exists($name, (array) $sampling); - } - - /** - * Send a request to the client and wait for a response (blocking). - * - * This suspends the Fiber and waits for the client to respond. The transport - * handles polling the session for the response and resuming the Fiber when ready. - * - * @param Request $request The request to send - * @param int $timeout Maximum time to wait for response (seconds) - * - * @return Response>|Error The client's response message - * - * @throws RuntimeException If Fiber support is not available - */ - private function request(Request $request, int $timeout = 120): Response|Error - { - $response = \Fiber::suspend([ - 'type' => 'request', - 'request' => $request, - 'session_id' => $this->session->getId()->toRfc4122(), - 'timeout' => $timeout, - ]); - - if (!$response instanceof Response && !$response instanceof Error) { - throw new RuntimeException('Transport returned an unexpected payload; expected a Response or Error message.'); - } - - return $response; - } -} diff --git a/src/Server/Configuration.php b/src/Server/Configuration.php deleted file mode 100644 index 2f0bd7f0..00000000 --- a/src/Server/Configuration.php +++ /dev/null @@ -1,41 +0,0 @@ - - */ -class Configuration -{ - /** - * @param Implementation $serverInfo info about this MCP server application - * @param ServerCapabilities $capabilities capabilities of this MCP server application - * @param int $paginationLimit maximum number of items to return for list methods - * @param string|null $instructions instructions describing how to use the server and its features - */ - public function __construct( - public readonly Implementation $serverInfo, - public readonly ServerCapabilities $capabilities, - public readonly int $paginationLimit = 50, - public readonly ?string $instructions = null, - public readonly ?ProtocolVersion $protocolVersion = null, - ) { - } -} diff --git a/src/Server/Handler/ElementHandlerInterface.php b/src/Server/Handler/ElementHandlerInterface.php deleted file mode 100644 index 6b08955d..00000000 --- a/src/Server/Handler/ElementHandlerInterface.php +++ /dev/null @@ -1,23 +0,0 @@ - - */ -interface ElementHandlerInterface -{ -} diff --git a/src/Server/Handler/Notification/InitializedHandler.php b/src/Server/Handler/Notification/InitializedHandler.php deleted file mode 100644 index 2dccc896..00000000 --- a/src/Server/Handler/Notification/InitializedHandler.php +++ /dev/null @@ -1,34 +0,0 @@ - - */ -final class InitializedHandler implements NotificationHandlerInterface -{ - public function supports(Notification $notification): bool - { - return $notification instanceof InitializedNotification; - } - - public function handle(Notification $notification, SessionInterface $session): void - { - \assert($notification instanceof InitializedNotification); - - $session->set('initialized', true); - } -} diff --git a/src/Server/Handler/Notification/NotificationHandlerInterface.php b/src/Server/Handler/Notification/NotificationHandlerInterface.php deleted file mode 100644 index 8746dc73..00000000 --- a/src/Server/Handler/Notification/NotificationHandlerInterface.php +++ /dev/null @@ -1,25 +0,0 @@ - - */ -interface NotificationHandlerInterface -{ - public function supports(Notification $notification): bool; - - public function handle(Notification $notification, SessionInterface $session): void; -} diff --git a/src/Server/Handler/PromptHandlerInterface.php b/src/Server/Handler/PromptHandlerInterface.php deleted file mode 100644 index 4143c976..00000000 --- a/src/Server/Handler/PromptHandlerInterface.php +++ /dev/null @@ -1,28 +0,0 @@ - - */ -interface PromptHandlerInterface extends ElementHandlerInterface -{ - /** - * @param array $arguments - */ - public function get(array $arguments, ClientGateway $gateway): mixed; -} diff --git a/src/Server/Handler/Request/CallToolHandler.php b/src/Server/Handler/Request/CallToolHandler.php deleted file mode 100644 index 254e8388..00000000 --- a/src/Server/Handler/Request/CallToolHandler.php +++ /dev/null @@ -1,155 +0,0 @@ - - * - * @author Christopher Hertel - * @author Tobias Nyholm - */ -final class CallToolHandler implements RequestHandlerInterface -{ - private SchemaValidator $schemaValidator; - - public function __construct( - private readonly RegistryInterface $registry, - private readonly ReferenceHandlerInterface $referenceHandler, - private readonly LoggerInterface $logger = new NullLogger(), - ?SchemaValidator $schemaValidator = null, - ) { - $this->schemaValidator = $schemaValidator ?? new SchemaValidator($logger); - } - - 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); - - $toolName = $request->name; - $arguments = $request->arguments; - - $this->logger->debug('Executing tool', ['name' => $toolName, 'arguments' => $arguments]); - - try { - $reference = $this->registry->getTool($toolName); - } catch (ToolNotFoundException $e) { - $this->logger->error('Tool not found', ['name' => $toolName, 'exception' => $e]); - - return new Error($request->getId(), Error::METHOD_NOT_FOUND, $e->getMessage()); - } - - $inputSchema = $reference->tool->inputSchema; - $validationErrors = $this->schemaValidator->validateAgainstJsonSchema($arguments, $inputSchema); - if (!empty($validationErrors)) { - $errorMessages = []; - - foreach ($validationErrors as $errorDetail) { - $pointer = $errorDetail['pointer'] ?? ''; - $message = $errorDetail['message'] ?? 'Unknown validation error'; - $errorMessages[] = ('/' !== $pointer && '' !== $pointer ? "Property '{$pointer}': " : '').$message; - } - - $summaryMessage = "Invalid parameters for tool '{$toolName}': ".implode('; ', \array_slice($errorMessages, 0, 3)); - if (\count($errorMessages) > 3) { - $summaryMessage .= '; ...and more errors.'; - } - - return Error::forInvalidParams($summaryMessage, $request->getId(), ['validation_errors' => $validationErrors]); - } - - $arguments['_session'] = $session; - $arguments['_request'] = $request; - - $context = new RequestContext($session, $request); - - try { - $result = $this->referenceHandler->handle($reference, $arguments); - - $protocolVersion = $context->getProtocolVersion(); - - $structuredContent = null; - if (!$result instanceof CallToolResult) { - $structuredContent = $reference->extractStructuredContent($result, $protocolVersion); - - if (null === $structuredContent && null !== $reference->tool->outputSchema) { - $this->logger->warning('Tool declares an "outputSchema" but returned a value that cannot be sent as "structuredContent"; the value is only carried in "content".', [ - 'name' => $toolName, - 'result_type' => get_debug_type($result), - ]); - } - - $result = new CallToolResult($reference->formatResult($result), structuredContent: $structuredContent); - } elseif ($protocolVersion->requiresObjectStructuredContent() - && \is_array($result->structuredContent) - && [] !== $result->structuredContent - && array_is_list($result->structuredContent) - ) { - // A tool building its own `CallToolResult` bypasses the extraction - // rules on purpose, so the value is sent as it was set — but a JSON - // array is not valid here before SEP-2106 and clients may reject it. - $this->logger->warning('Tool returned a "CallToolResult" whose "structuredContent" is a JSON array, which the negotiated protocol revision does not allow; sending it unchanged.', [ - 'name' => $toolName, - 'protocol_version' => $protocolVersion->value, - ]); - } - - $this->logger->debug('Tool executed successfully', [ - 'name' => $toolName, - 'result_type' => \gettype($result), - 'structured_content' => $structuredContent, - ]); - - return new Response($request->getId(), $result); - } catch (ToolCallException $e) { - $this->logger->error(\sprintf('Error while executing tool "%s": "%s".', $toolName, $e->getMessage()), [ - 'tool' => $toolName, - 'arguments' => $arguments, - 'exception' => $e, - ]); - - $errorContent = [new TextContent($e->getMessage())]; - - return new Response($request->getId(), CallToolResult::error($errorContent)); - } catch (\Throwable $e) { - $this->logger->error('Unhandled error during tool execution', [ - 'name' => $toolName, - 'exception' => $e, - ]); - - return Error::forInternalError('Error while executing tool', $request->getId()); - } - } -} diff --git a/src/Server/Handler/Request/CompletionCompleteHandler.php b/src/Server/Handler/Request/CompletionCompleteHandler.php deleted file mode 100644 index b3c4d043..00000000 --- a/src/Server/Handler/Request/CompletionCompleteHandler.php +++ /dev/null @@ -1,103 +0,0 @@ - - * - * @author Kyrian Obikwelu - */ -final class CompletionCompleteHandler implements RequestHandlerInterface -{ - public function __construct( - private readonly RegistryInterface $registry, - private readonly ?ContainerInterface $container = null, - ) { - } - - public function supports(Request $request): bool - { - return $request instanceof CompletionCompleteRequest; - } - - /** - * @return Response|Error - */ - public function handle(Request $request, SessionInterface $session): Response|Error - { - \assert($request instanceof CompletionCompleteRequest); - - $name = $request->argument['name'] ?? ''; - $value = $request->argument['value'] ?? ''; - - try { - $providers = match (true) { - $request->ref instanceof PromptReference => $this->registry->getPrompt($request->ref->name)->completionProviders, - $request->ref instanceof ResourceReference => $this->resourceCompletionProviders($request->ref->uri), - }; - - $provider = $providers[$name] ?? null; - if (null === $provider) { - return new Response($request->getId(), new CompletionCompleteResult([])); - } - - if (\is_string($provider)) { - if (!class_exists($provider)) { - return Error::forInternalError('Invalid completion provider', $request->getId()); - } - $provider = $this->container?->has($provider) ? $this->container->get($provider) : new $provider(); - } - - if (!$provider instanceof ProviderInterface) { - return Error::forInternalError('Invalid completion provider type', $request->getId()); - } - - $completions = $provider->getCompletions($value); - $total = \count($completions); - $hasMore = $total > 100; - $paged = \array_slice($completions, 0, 100); - - return new Response($request->getId(), new CompletionCompleteResult($paged, $total, $hasMore)); - } catch (PromptNotFoundException|ResourceNotFoundException $e) { - return Error::forResourceNotFound($e->getMessage(), $request->getId()); - } catch (\Throwable $e) { - return Error::forInternalError('Error while handling completion request', $request->getId()); - } - } - - /** - * @return array - */ - private function resourceCompletionProviders(string $uri): array - { - $reference = $this->registry->getResource($uri); - - return $reference instanceof ResourceTemplateReference ? $reference->completionProviders : []; - } -} diff --git a/src/Server/Handler/Request/GetPromptHandler.php b/src/Server/Handler/Request/GetPromptHandler.php deleted file mode 100644 index 745b9b86..00000000 --- a/src/Server/Handler/Request/GetPromptHandler.php +++ /dev/null @@ -1,81 +0,0 @@ - - * - * @author Tobias Nyholm - */ -final class GetPromptHandler implements RequestHandlerInterface -{ - public function __construct( - private readonly RegistryInterface $registry, - private readonly ReferenceHandlerInterface $referenceHandler, - private readonly LoggerInterface $logger = new NullLogger(), - ) { - } - - public function supports(Request $request): bool - { - return $request instanceof GetPromptRequest; - } - - /** - * @return Response|Error - */ - public function handle(Request $request, SessionInterface $session): Response|Error - { - \assert($request instanceof GetPromptRequest); - - $promptName = $request->name; - $arguments = $request->arguments ?? []; - - try { - $reference = $this->registry->getPrompt($promptName); - - $arguments['_session'] = $session; - $arguments['_request'] = $request; - - $result = $this->referenceHandler->handle($reference, $arguments); - - $formatted = $reference->formatResult($result); - - return new Response($request->getId(), new GetPromptResult($formatted)); - } catch (PromptGetException $e) { - $this->logger->error(\sprintf('Error while handling prompt "%s": "%s".', $promptName, $e->getMessage()), ['exception' => $e]); - - return Error::forInternalError($e->getMessage(), $request->getId()); - } catch (PromptNotFoundException $e) { - $this->logger->error('Prompt not found', ['prompt_name' => $promptName, 'exception' => $e]); - - return Error::forResourceNotFound($e->getMessage(), $request->getId()); - } catch (\Throwable $e) { - $this->logger->error(\sprintf('Unexpected error while handling prompt "%s": "%s".', $promptName, $e->getMessage()), ['exception' => $e]); - - return Error::forInternalError('Error while handling prompt', $request->getId()); - } - } -} diff --git a/src/Server/Handler/Request/InitializeHandler.php b/src/Server/Handler/Request/InitializeHandler.php deleted file mode 100644 index 4461ae41..00000000 --- a/src/Server/Handler/Request/InitializeHandler.php +++ /dev/null @@ -1,106 +0,0 @@ - - * - * @author Christopher Hertel - */ -final class InitializeHandler implements RequestHandlerInterface -{ - public function __construct( - public readonly ?Configuration $configuration = null, - ) { - } - - public function supports(Request $request): bool - { - return $request instanceof InitializeRequest; - } - - /** - * @return Response - */ - public function handle(Request $request, SessionInterface $session): Response - { - \assert($request instanceof InitializeRequest); - - $session->set('client_info', $request->clientInfo->jsonSerialize()); - $session->set('client_capabilities', $request->capabilities->jsonSerialize()); - - $negotiated = $this->negotiate($request->protocolVersion); - $session->set('protocol_version', $negotiated->value); - - return new Response( - $request->getId(), - new InitializeResult( - $this->configuration->capabilities ?? new ServerCapabilities(), - $this->configuration->serverInfo ?? new Implementation(), - $this->configuration?->instructions, - null, - $negotiated, - ), - ); - } - - /** - * Picks the protocol version to answer an `initialize` handshake with. - * - * If the client asked for a version this server supports, the spec requires - * responding with that exact version. Otherwise the server counter-offers - * the newest version it does support and leaves it to the client to decide - * whether it can continue on that revision or must disconnect. - */ - private function negotiate(string $requested): ProtocolVersion - { - $supported = $this->supportedVersions(); - $version = ProtocolVersion::tryFrom($requested); - - if (null !== $version && \in_array($version, $supported, true)) { - return $version; - } - - return $supported[\count($supported) - 1]; - } - - /** - * Versions this server is willing to negotiate over `initialize`. - * - * A version configured on the server pins the handshake to exactly that - * revision. Modern revisions are never offered here: they have no - * `initialize` at all, so a client that reached this handler cannot speak - * one, and answering with it would leave the connection unusable. - * - * @return non-empty-list - */ - private function supportedVersions(): array - { - $configured = $this->configuration?->protocolVersion; - - if (null !== $configured && !$configured->isModern()) { - return [$configured]; - } - - return ProtocolVersion::handshakeVersions(); - } -} diff --git a/src/Server/Handler/Request/ListPromptsHandler.php b/src/Server/Handler/Request/ListPromptsHandler.php deleted file mode 100644 index ee287560..00000000 --- a/src/Server/Handler/Request/ListPromptsHandler.php +++ /dev/null @@ -1,56 +0,0 @@ - - * - * @author Tobias Nyholm - */ -final class ListPromptsHandler implements RequestHandlerInterface -{ - public function __construct( - private readonly RegistryInterface $registry, - private readonly int $pageSize = 20, - ) { - } - - public function supports(Request $request): bool - { - return $request instanceof ListPromptsRequest; - } - - /** - * @return Response - * - * @throws InvalidCursorException - */ - public function handle(Request $request, SessionInterface $session): Response - { - \assert($request instanceof ListPromptsRequest); - - $page = $this->registry->getPrompts($this->pageSize, $request->cursor); - - return new Response( - $request->getId(), - new ListPromptsResult($page->references, $page->nextCursor), - ); - } -} diff --git a/src/Server/Handler/Request/ListResourceTemplatesHandler.php b/src/Server/Handler/Request/ListResourceTemplatesHandler.php deleted file mode 100644 index 5360f75e..00000000 --- a/src/Server/Handler/Request/ListResourceTemplatesHandler.php +++ /dev/null @@ -1,56 +0,0 @@ - - * - * @author Christopher Hertel - */ -final class ListResourceTemplatesHandler implements RequestHandlerInterface -{ - public function __construct( - private readonly RegistryInterface $registry, - private readonly int $pageSize = 20, - ) { - } - - public function supports(Request $request): bool - { - return $request instanceof ListResourceTemplatesRequest; - } - - /** - * @return Response - * - * @throws InvalidCursorException - */ - public function handle(Request $request, SessionInterface $session): Response - { - \assert($request instanceof ListResourceTemplatesRequest); - - $page = $this->registry->getResourceTemplates($this->pageSize, $request->cursor); - - return new Response( - $request->getId(), - new ListResourceTemplatesResult($page->references, $page->nextCursor), - ); - } -} diff --git a/src/Server/Handler/Request/ListResourcesHandler.php b/src/Server/Handler/Request/ListResourcesHandler.php deleted file mode 100644 index d1d68307..00000000 --- a/src/Server/Handler/Request/ListResourcesHandler.php +++ /dev/null @@ -1,56 +0,0 @@ - - * - * @author Tobias Nyholm - */ -final class ListResourcesHandler implements RequestHandlerInterface -{ - public function __construct( - private readonly RegistryInterface $registry, - private readonly int $pageSize = 20, - ) { - } - - public function supports(Request $request): bool - { - return $request instanceof ListResourcesRequest; - } - - /** - * @return Response - * - * @throws InvalidCursorException - */ - public function handle(Request $request, SessionInterface $session): Response - { - \assert($request instanceof ListResourcesRequest); - - $page = $this->registry->getResources($this->pageSize, $request->cursor); - - return new Response( - $request->getId(), - new ListResourcesResult($page->references, $page->nextCursor), - ); - } -} diff --git a/src/Server/Handler/Request/ListToolsHandler.php b/src/Server/Handler/Request/ListToolsHandler.php deleted file mode 100644 index 007bdd50..00000000 --- a/src/Server/Handler/Request/ListToolsHandler.php +++ /dev/null @@ -1,57 +0,0 @@ - - * - * @author Christopher Hertel - * @author Tobias Nyholm - */ -final class ListToolsHandler implements RequestHandlerInterface -{ - public function __construct( - private readonly RegistryInterface $registry, - private readonly int $pageSize = 20, - ) { - } - - public function supports(Request $request): bool - { - return $request instanceof ListToolsRequest; - } - - /** - * @return Response - * - * @throws InvalidCursorException When the cursor is invalid - */ - public function handle(Request $request, SessionInterface $session): Response - { - \assert($request instanceof ListToolsRequest); - - $page = $this->registry->getTools($this->pageSize, $request->cursor); - - return new Response( - $request->getId(), - new ListToolsResult($page->references, $page->nextCursor), - ); - } -} diff --git a/src/Server/Handler/Request/PingHandler.php b/src/Server/Handler/Request/PingHandler.php deleted file mode 100644 index 507680fa..00000000 --- a/src/Server/Handler/Request/PingHandler.php +++ /dev/null @@ -1,41 +0,0 @@ - - * - * @author Christopher Hertel - */ -final class PingHandler implements RequestHandlerInterface -{ - public function supports(Request $request): bool - { - return $request instanceof PingRequest; - } - - /** - * @return Response - */ - public function handle(Request $request, SessionInterface $session): Response - { - \assert($request instanceof PingRequest); - - return new Response($request->getId(), new EmptyResult()); - } -} diff --git a/src/Server/Handler/Request/ReadResourceHandler.php b/src/Server/Handler/Request/ReadResourceHandler.php deleted file mode 100644 index a9551eff..00000000 --- a/src/Server/Handler/Request/ReadResourceHandler.php +++ /dev/null @@ -1,93 +0,0 @@ - - * - * @author Tobias Nyholm - */ -final class ReadResourceHandler implements RequestHandlerInterface -{ - public function __construct( - private readonly RegistryInterface $referenceProvider, - private readonly ReferenceHandlerInterface $referenceHandler, - private readonly LoggerInterface $logger = new NullLogger(), - ) { - } - - public function supports(Request $request): bool - { - return $request instanceof ReadResourceRequest; - } - - /** - * @return Response|Error - */ - public function handle(Request $request, SessionInterface $session): Response|Error - { - \assert($request instanceof ReadResourceRequest); - - $uri = $request->uri; - - $this->logger->debug('Reading resource', ['uri' => $uri]); - - try { - $reference = $this->referenceProvider->getResource($uri); - - $arguments = [ - 'uri' => $uri, - '_session' => $session, - '_request' => $request, - ]; - - if ($reference instanceof ResourceTemplateReference) { - $variables = $reference->extractVariables($uri); - $arguments = array_merge($arguments, $variables); - - $result = $this->referenceHandler->handle($reference, $arguments); - $formatted = $reference->formatResult($result, $uri, $reference->resourceTemplate->mimeType); - } else { - $result = $this->referenceHandler->handle($reference, $arguments); - $formatted = $reference->formatResult($result, $uri, $reference->resource->mimeType); - } - - return new Response($request->getId(), new ReadResourceResult($formatted)); - } catch (ResourceReadException $e) { - $this->logger->error(\sprintf('Error while reading resource "%s": "%s".', $uri, $e->getMessage()), ['exception' => $e]); - - return Error::forInternalError($e->getMessage(), $request->getId()); - } catch (ResourceNotFoundException $e) { - $this->logger->error('Resource not found', ['uri' => $uri, 'exception' => $e]); - - return Error::forResourceNotFound($e->getMessage(), $request->getId()); - } catch (\Throwable $e) { - $this->logger->error(\sprintf('Unexpected error while reading resource "%s": "%s".', $uri, $e->getMessage()), ['exception' => $e]); - - return Error::forInternalError('Error while reading resource', $request->getId()); - } - } -} diff --git a/src/Server/Handler/Request/RequestHandlerInterface.php b/src/Server/Handler/Request/RequestHandlerInterface.php deleted file mode 100644 index d81c0795..00000000 --- a/src/Server/Handler/Request/RequestHandlerInterface.php +++ /dev/null @@ -1,32 +0,0 @@ - - */ -interface RequestHandlerInterface -{ - public function supports(Request $request): bool; - - /** - * @return Response|Error - */ - public function handle(Request $request, SessionInterface $session): Response|Error; -} diff --git a/src/Server/Handler/Request/ResourceSubscribeHandler.php b/src/Server/Handler/Request/ResourceSubscribeHandler.php deleted file mode 100644 index b5421f87..00000000 --- a/src/Server/Handler/Request/ResourceSubscribeHandler.php +++ /dev/null @@ -1,72 +0,0 @@ - - * - * @author Larry Sule-balogun - */ -final class ResourceSubscribeHandler implements RequestHandlerInterface -{ - public function __construct( - private readonly RegistryInterface $registry, - private readonly SubscriptionManagerInterface $subscriptionManager, - private readonly LoggerInterface $logger = new NullLogger(), - ) { - } - - public function supports(Request $request): bool - { - return $request instanceof ResourceSubscribeRequest; - } - - /** - * @throws InvalidArgumentException - */ - public function handle(Request $request, SessionInterface $session): Response|Error - { - \assert($request instanceof ResourceSubscribeRequest); - - $uri = $request->uri; - - try { - $this->registry->getResource($uri); - } catch (ResourceNotFoundException $e) { - $this->logger->error('Resource not found', ['uri' => $uri, 'exception' => $e]); - - return Error::forResourceNotFound($e->getMessage(), $request->getId()); - } - - $this->logger->debug('Subscribing to resource', ['uri' => $uri]); - - $this->subscriptionManager->subscribe($session, $uri); - - return new Response( - $request->getId(), - new EmptyResult(), - ); - } -} diff --git a/src/Server/Handler/Request/ResourceUnsubscribeHandler.php b/src/Server/Handler/Request/ResourceUnsubscribeHandler.php deleted file mode 100644 index 50ab8bc1..00000000 --- a/src/Server/Handler/Request/ResourceUnsubscribeHandler.php +++ /dev/null @@ -1,72 +0,0 @@ - - * - * @author Larry Sule-balogun - */ -final class ResourceUnsubscribeHandler implements RequestHandlerInterface -{ - public function __construct( - private readonly RegistryInterface $registry, - private readonly SubscriptionManagerInterface $subscriptionManager, - private readonly LoggerInterface $logger = new NullLogger(), - ) { - } - - public function supports(Request $request): bool - { - return $request instanceof ResourceUnsubscribeRequest; - } - - /** - * @throws InvalidArgumentException - */ - public function handle(Request $request, SessionInterface $session): Response|Error - { - \assert($request instanceof ResourceUnsubscribeRequest); - - $uri = $request->uri; - - try { - $this->registry->getResource($uri); - } catch (ResourceNotFoundException $e) { - $this->logger->error('Resource not found', ['uri' => $uri, 'exception' => $e]); - - return Error::forResourceNotFound($e->getMessage(), $request->getId()); - } - - $this->logger->debug('Unsubscribing from resource', ['uri' => $uri]); - - $this->subscriptionManager->unsubscribe($session, $uri); - - return new Response( - $request->getId(), - new EmptyResult(), - ); - } -} diff --git a/src/Server/Handler/Request/SetLogLevelHandler.php b/src/Server/Handler/Request/SetLogLevelHandler.php deleted file mode 100644 index 55638983..00000000 --- a/src/Server/Handler/Request/SetLogLevelHandler.php +++ /dev/null @@ -1,49 +0,0 @@ - - * - * @author Adam Jamiu - */ -final class SetLogLevelHandler implements RequestHandlerInterface -{ - public function supports(Request $request): bool - { - return $request instanceof SetLogLevelRequest; - } - - /** - * @return Response - */ - public function handle(Request $request, SessionInterface $session): Response - { - \assert($request instanceof SetLogLevelRequest); - - $session->set(Protocol::SESSION_LOGGING_LEVEL, $request->level->value); - - return new Response($request->getId(), new EmptyResult()); - } -} diff --git a/src/Server/Handler/ResourceHandlerInterface.php b/src/Server/Handler/ResourceHandlerInterface.php deleted file mode 100644 index 7ca533a9..00000000 --- a/src/Server/Handler/ResourceHandlerInterface.php +++ /dev/null @@ -1,25 +0,0 @@ - - */ -interface ResourceHandlerInterface extends ElementHandlerInterface -{ - public function read(string $uri, ClientGateway $gateway): mixed; -} diff --git a/src/Server/Handler/ResourceTemplateHandlerInterface.php b/src/Server/Handler/ResourceTemplateHandlerInterface.php deleted file mode 100644 index 892e4393..00000000 --- a/src/Server/Handler/ResourceTemplateHandlerInterface.php +++ /dev/null @@ -1,28 +0,0 @@ - - */ -interface ResourceTemplateHandlerInterface extends ElementHandlerInterface -{ - /** - * @param array $variables - */ - public function read(string $uri, array $variables, ClientGateway $gateway): mixed; -} diff --git a/src/Server/Handler/ToolHandlerInterface.php b/src/Server/Handler/ToolHandlerInterface.php deleted file mode 100644 index 4f2ee3e9..00000000 --- a/src/Server/Handler/ToolHandlerInterface.php +++ /dev/null @@ -1,28 +0,0 @@ - - */ -interface ToolHandlerInterface extends ElementHandlerInterface -{ - /** - * @param array $arguments - */ - public function execute(array $arguments, ClientGateway $gateway): mixed; -} diff --git a/src/Server/NativeClock.php b/src/Server/NativeClock.php deleted file mode 100644 index fc18e4e5..00000000 --- a/src/Server/NativeClock.php +++ /dev/null @@ -1,22 +0,0 @@ - - * @author Kyrian Obikwelu - */ -class Protocol -{ - /** Session key for request ID counter */ - private const SESSION_REQUEST_ID_COUNTER = '_mcp.request_id_counter'; - - /** Session key for pending outgoing requests */ - private const SESSION_PENDING_REQUESTS = '_mcp.pending_requests'; - - /** Session key for incoming client responses */ - private const SESSION_RESPONSES = '_mcp.responses'; - - /** Session key for outgoing message queue */ - private const SESSION_OUTGOING_QUEUE = '_mcp.outgoing_queue'; - - /** Session key for active request meta */ - public const SESSION_ACTIVE_REQUEST_META = '_mcp.active_request_meta'; - - public const SESSION_LOGGING_LEVEL = '_mcp.logging_level'; - - /** - * Deliberately generic: unexpected throwables carry internal details such as file paths, class - * names and argument types, which must not be handed to the peer. The full exception is logged. - */ - private const INTERNAL_ERROR_MESSAGE = 'Internal server error.'; - - /** - * @param array>> $requestHandlers - * @param array $notificationHandlers - */ - public function __construct( - private readonly array $requestHandlers, - private readonly array $notificationHandlers, - private readonly MessageFactory $messageFactory, - private readonly SessionManagerInterface $sessionManager, - private readonly LoggerInterface $logger = new NullLogger(), - private readonly ?EventDispatcherInterface $eventDispatcher = null, - ) { - } - - /** - * Connect this protocol to transport. - * - * The protocol takes ownership of the transport and sets up all callbacks. - * - * @param TransportInterface $transport - */ - public function connect(TransportInterface $transport): void - { - $transport->onMessage($this->processInput(...)); - - $transport->onSessionEnd($this->destroySession(...)); - - $transport->setOutgoingMessagesProvider($this->consumeOutgoingMessages(...)); - - $transport->setPendingRequestsProvider($this->getPendingRequests(...)); - - $transport->setResponseFinder($this->checkResponse(...)); - - $transport->setFiberYieldHandler($this->handleFiberYield(...)); - - $this->logger->info('Protocol connected to transport', ['transport' => $transport::class]); - } - - /** - * Handle an incoming message from the transport. - * - * This is called by the transport whenever ANY message arrives. - * - * @param TransportInterface $transport - */ - public function processInput(TransportInterface $transport, string $input, ?Uuid $sessionId): void - { - // Last line of defense: a malformed message must never escape as a PHP error and take the - // server process down. - try { - $this->doProcessInput($transport, $input, $sessionId); - } catch (\Throwable $e) { - $this->logger->error(\sprintf('Uncaught exception while processing input: %s', $e->getMessage()), ['exception' => $e]); - - // Only a request may be answered. Replying to a notification would violate JSON-RPC, - // and the failure has already been logged. - if (null === $id = self::findResponseId($input)) { - return; - } - - try { - $this->sendResponse($transport, Error::forInternalError(self::INTERNAL_ERROR_MESSAGE, $id), null); - } catch (\Throwable $e) { - $this->logger->error(\sprintf('Failed to send internal error response: %s', $e->getMessage()), ['exception' => $e]); - } - } - } - - /** - * Determines the id an unprocessable input has to be answered under. - * - * Returns null when the input carries no request at all, in which case it consists of - * notifications only and JSON-RPC forbids answering it. A batch resolves to the empty id - * because its failure cannot be attributed to one of its requests. - */ - private static function findResponseId(string $input): string|int|null - { - try { - $data = json_decode($input, true, flags: \JSON_THROW_ON_ERROR); - } catch (\JsonException) { - return null; - } - - if (!\is_array($data)) { - return null; - } - - if (!array_is_list($data)) { - $id = $data['id'] ?? null; - - return \is_string($id) || \is_int($id) ? $id : null; - } - - foreach ($data as $message) { - if (\is_array($message) && isset($message['id'])) { - return ''; - } - } - - return null; - } - - /** - * @param TransportInterface $transport - */ - private function doProcessInput(TransportInterface $transport, string $input, ?Uuid $sessionId): void - { - $this->logger->info('Received message to process.', ['message' => $input]); - - $this->sessionManager->gc(); - - try { - $messages = $this->messageFactory->create($input); - } catch (\JsonException $e) { - $this->logger->warning('Failed to decode json message.', ['exception' => $e]); - $error = Error::forParseError($e->getMessage()); - $this->sendResponse($transport, $error, null); - - return; - } - - $session = $this->resolveSession($transport, $sessionId, $messages); - if (null === $session) { - return; - } - - foreach ($messages as $message) { - // Guarded per message so one faulty message cannot suppress the rest of a batch. - try { - if ($message instanceof InvalidInputMessageException) { - $this->handleInvalidMessage($transport, $message, $session); - } elseif ($message instanceof Request) { - $this->handleRequest($transport, $message, $session); - } elseif ($message instanceof Response || $message instanceof Error) { - $this->handleResponse($message, $session); - } elseif ($message instanceof Notification) { - $this->handleNotification($message, $session); - } - } catch (\Throwable $e) { - $this->logger->error(\sprintf('Uncaught exception while handling message: %s', $e->getMessage()), ['exception' => $e]); - - // Only a request may be answered; a notification or a response must not produce one. - if ($message instanceof Request) { - $error = Error::forInternalError(self::INTERNAL_ERROR_MESSAGE, $message->getId()); - $this->sendResponse($transport, $error, $session); - } - } - } - - $session->save(); - } - - /** - * Handle an invalid message from the transport. - * - * @param TransportInterface $transport - */ - private function handleInvalidMessage(TransportInterface $transport, InvalidInputMessageException $exception, SessionInterface $session): void - { - $this->logger->warning('Failed to create message.', ['exception' => $exception]); - - $error = Error::forInvalidRequest($exception->getMessage()); - $this->sendResponse($transport, $error, $session); - } - - /** - * Dispatches an event through the event dispatcher if available. - * - * @template T of object - * - * @param T $event - * - * @return T - */ - private function dispatchEvent(object $event): object - { - return $this->eventDispatcher?->dispatch($event) ?? $event; - } - - /** - * Handle a request from the transport. - * - * @param TransportInterface $transport - */ - private function handleRequest(TransportInterface $transport, Request $request, SessionInterface $session): void - { - $this->logger->info('Handling request.', ['request' => $request]); - - $session->set(self::SESSION_ACTIVE_REQUEST_META, $request->getMeta()); - - $event = $this->dispatchEvent(new RequestEvent($request, $session)); - $request = $event->getRequest(); - - $handlerFound = false; - - foreach ($this->requestHandlers as $handler) { - if (!$handler->supports($request)) { - continue; - } - - $handlerFound = true; - - try { - /** @var McpFiber $fiber */ - $fiber = new \Fiber(static fn () => $handler->handle($request, $session)); - - $result = $fiber->start(); - - if ($fiber->isSuspended()) { - if (\is_array($result) && isset($result['type'])) { - if ('notification' === $result['type']) { - $notification = $result['notification']; - $this->sendNotification($notification, $session); - } elseif ('request' === $result['type']) { - $request = $result['request']; - $timeout = $result['timeout'] ?? 120; - $this->sendRequest($request, $timeout, $session); - } - } - - $transport->attachFiberToSession($fiber, $session->getId()); - - return; - } - $finalResult = $fiber->getReturn(); - - if ($finalResult instanceof Response) { - $responseEvent = $this->dispatchEvent(new ResponseEvent($finalResult, $request, $session)); - $finalResult = $responseEvent->getResponse(); - } elseif ($finalResult instanceof Error) { - $errorEvent = $this->dispatchEvent(new ErrorEvent($finalResult, $request, $session, null)); - $finalResult = $errorEvent->getError(); - } - - $this->sendResponse($transport, $finalResult, $session); - } catch (\InvalidArgumentException $e) { - $this->logger->warning(\sprintf('Invalid argument: %s', $e->getMessage()), ['exception' => $e]); - - $error = Error::forInvalidParams($e->getMessage(), $request->getId()); - $errorEvent = $this->dispatchEvent(new ErrorEvent($error, $request, $session, $e)); - $error = $errorEvent->getError(); - - $this->sendResponse($transport, $error, $session); - } catch (\Throwable $e) { - $this->logger->error(\sprintf('Uncaught exception: %s', $e->getMessage()), ['exception' => $e]); - - $error = Error::forInternalError(self::INTERNAL_ERROR_MESSAGE, $request->getId()); - $errorEvent = $this->dispatchEvent(new ErrorEvent($error, $request, $session, $e)); - $error = $errorEvent->getError(); - - $this->sendResponse($transport, $error, $session); - } - - break; - } - - if (!$handlerFound) { - $error = Error::forMethodNotFound(\sprintf('No handler found for method "%s".', $request::getMethod()), $request->getId()); - $errorEvent = $this->dispatchEvent(new ErrorEvent($error, $request, $session, null)); - $error = $errorEvent->getError(); - - $this->sendResponse($transport, $error, $session); - } - } - - /** - * @param Response>|Error $response - */ - private function handleResponse(Response|Error $response, SessionInterface $session): void - { - $this->logger->info('Handling response from client.', ['response' => $response]); - - $messageId = $response->getId(); - - $session->set(self::SESSION_RESPONSES.".{$messageId}", $response->jsonSerialize()); - $session->forget(self::SESSION_ACTIVE_REQUEST_META); - - $this->logger->info('Client response stored in session', [ - 'message_id' => $messageId, - ]); - } - - private function handleNotification(Notification $notification, SessionInterface $session): void - { - $this->logger->info('Handling notification.', ['notification' => $notification]); - - $event = $this->dispatchEvent(new NotificationEvent($notification, $session)); - $notification = $event->getNotification(); - - foreach ($this->notificationHandlers as $handler) { - if (!$handler->supports($notification)) { - continue; - } - - try { - $handler->handle($notification, $session); - } catch (\Throwable $e) { - $this->logger->error(\sprintf('Error while handling notification: %s', $e->getMessage()), ['exception' => $e]); - } - } - } - - /** - * Sends a request to the client and returns the request ID. - */ - public function sendRequest(Request $request, int $timeout, SessionInterface $session): int - { - $counter = $session->get(self::SESSION_REQUEST_ID_COUNTER, 1000); - $requestId = $counter++; - $session->set(self::SESSION_REQUEST_ID_COUNTER, $counter); - - $requestWithId = $request->withId($requestId); - - $this->logger->info('Queueing server request to client', [ - 'request_id' => $requestId, - 'method' => $request::getMethod(), - ]); - - $pending = $session->get(self::SESSION_PENDING_REQUESTS, []); - $pending[$requestId] = [ - 'request_id' => $requestId, - 'timeout' => $timeout, - 'timestamp' => time(), - ]; - $session->set(self::SESSION_PENDING_REQUESTS, $pending); - - $this->queueOutgoing($requestWithId, ['type' => 'request'], $session); - - return $requestId; - } - - /** - * Queues a notification for later delivery. - */ - public function sendNotification(Notification $notification, SessionInterface $session): void - { - $this->logger->info('Queueing server notification to client', [ - 'method' => $notification::getMethod(), - ]); - - $this->queueOutgoing($notification, ['type' => 'notification'], $session); - } - - /** - * Sends a response either immediately or queued for later delivery. - * - * @param TransportInterface $transport - * @param Response>|Error $response - * @param array $context - */ - private function sendResponse(TransportInterface $transport, Response|Error $response, ?SessionInterface $session, array $context = []): void - { - if (null === $session) { - $this->logger->info('Sending immediate response', [ - 'response_id' => $response->getId(), - ]); - - try { - $encoded = json_encode($response, \JSON_THROW_ON_ERROR); - } catch (\JsonException $e) { - $this->logger->error('Failed to encode response to JSON.', [ - 'message_id' => $response->getId(), - 'exception' => $e, - ]); - - $fallbackError = new Error( - id: $response->getId(), - code: Error::INTERNAL_ERROR, - message: 'Response could not be encoded to JSON' - ); - - $encoded = json_encode($fallbackError, \JSON_THROW_ON_ERROR); - } - - $context['type'] = 'response'; - $transport->send($encoded, $context); - } else { - $this->logger->info('Queueing server response', [ - 'response_id' => $response->getId(), - ]); - - $this->queueOutgoing($response, ['type' => 'response'], $session); - } - } - - /** - * Helper to queue outgoing messages in session. - * - * @param Request|Notification|Response>|Error $message - * @param array $context - */ - private function queueOutgoing(Request|Notification|Response|Error $message, array $context, SessionInterface $session): void - { - try { - $encoded = json_encode($message, \JSON_THROW_ON_ERROR); - } catch (\JsonException $e) { - $this->logger->error('Failed to encode message to JSON.', [ - 'exception' => $e, - ]); - - return; - } - - $queue = $session->get(self::SESSION_OUTGOING_QUEUE, []); - $queue[] = [ - 'message' => $encoded, - 'context' => $context, - ]; - $session->set(self::SESSION_OUTGOING_QUEUE, $queue); - } - - /** - * Consume (get and clear) all outgoing messages for a session. - * - * @return array}> - */ - public function consumeOutgoingMessages(Uuid $sessionId): array - { - $session = $this->sessionManager->createWithId($sessionId); - $queue = $session->get(self::SESSION_OUTGOING_QUEUE, []); - $session->set(self::SESSION_OUTGOING_QUEUE, []); - $session->save(); - - return $queue; - } - - /** - * Check for a response to a specific request ID. - * - * When a response is found, it is removed from the session, and the - * corresponding pending request is also cleared. - */ - /** - * @return Response>|Error|null - */ - public function checkResponse(int $requestId, Uuid $sessionId): Response|Error|null - { - $session = $this->sessionManager->createWithId($sessionId); - $responseData = $session->get(self::SESSION_RESPONSES.".{$requestId}"); - - if (null === $responseData) { - return null; - } - - $this->logger->debug('Found and consuming client response.', [ - 'request_id' => $requestId, - 'session_id' => $sessionId->toRfc4122(), - ]); - - $session->set(self::SESSION_RESPONSES.".{$requestId}", null); - $pending = $session->get(self::SESSION_PENDING_REQUESTS, []); - unset($pending[$requestId]); - $session->set(self::SESSION_PENDING_REQUESTS, $pending); - $session->save(); - - try { - if (isset($responseData['error'])) { - return Error::fromArray($responseData); - } - - return Response::fromArray($responseData); - } catch (\Throwable $e) { - $this->logger->error('Failed to reconstruct client response from session.', [ - 'request_id' => $requestId, - 'exception' => $e, - 'response_data' => $responseData, - ]); - - return null; - } - } - - /** - * Get pending requests for a session. - * - * @return array The pending requests - */ - public function getPendingRequests(Uuid $sessionId): array - { - $session = $this->sessionManager->createWithId($sessionId); - - return $session->get(self::SESSION_PENDING_REQUESTS, []); - } - - /** - * Handle values yielded by Fibers during transport-managed resumes. - * - * @param FiberSuspend|null $yieldedValue - */ - public function handleFiberYield(mixed $yieldedValue, ?Uuid $sessionId): void - { - if (!$sessionId) { - $this->logger->warning('Fiber yielded value without associated session context.'); - - return; - } - - if (!\is_array($yieldedValue) || !isset($yieldedValue['type'])) { - $this->logger->warning('Fiber yielded unexpected payload.', [ - 'payload' => $yieldedValue, - 'session_id' => $sessionId->toRfc4122(), - ]); - - return; - } - - $session = $this->sessionManager->createWithId($sessionId); - - $payloadSessionId = $yieldedValue['session_id'] ?? null; - if (\is_string($payloadSessionId) && $payloadSessionId !== $sessionId->toRfc4122()) { - $this->logger->warning('Fiber yielded payload with mismatched session ID.', [ - 'payload_session_id' => $payloadSessionId, - 'expected_session_id' => $sessionId->toRfc4122(), - ]); - } - - try { - if ('notification' === $yieldedValue['type']) { - $notification = $yieldedValue['notification'] ?? null; - if (!$notification instanceof Notification) { - $this->logger->warning('Fiber yielded notification without Notification instance.', [ - 'payload' => $yieldedValue, - ]); - - return; - } - - $this->sendNotification($notification, $session); - } elseif ('request' === $yieldedValue['type']) { - $request = $yieldedValue['request'] ?? null; - if (!$request instanceof Request) { - $this->logger->warning('Fiber yielded request without Request instance.', [ - 'payload' => $yieldedValue, - ]); - - return; - } - - $timeout = isset($yieldedValue['timeout']) ? (int) $yieldedValue['timeout'] : 120; - $this->sendRequest($request, $timeout, $session); - } else { - $this->logger->warning('Fiber yielded unknown operation type.', [ - 'type' => $yieldedValue['type'], - ]); - } - } finally { - $session->save(); - } - } - - /** - * @param array $messages - */ - private function hasInitializeRequest(array $messages): bool - { - foreach ($messages as $message) { - if ($message instanceof InitializeRequest) { - return true; - } - } - - return false; - } - - /** - * Resolves and validates the session based on the request context. - * - * @param TransportInterface $transport - * @param Uuid|null $sessionId The session ID from the transport - * @param array $messages The parsed messages - */ - private function resolveSession(TransportInterface $transport, ?Uuid $sessionId, array $messages): ?SessionInterface - { - if ($this->hasInitializeRequest($messages)) { - // Spec: An initialize request must not be part of a batch. - if (\count($messages) > 1) { - $error = Error::forInvalidRequest('The "initialize" request MUST NOT be part of a batch.'); - $this->sendResponse($transport, $error, null); - - return null; - } - - // Spec: An initialize request must not have a session ID. - if ($sessionId) { - $error = Error::forInvalidRequest('A session ID MUST NOT be sent with an "initialize" request.'); - $this->sendResponse($transport, $error, null); - - return null; - } - - $session = $this->sessionManager->create(); - $this->logger->debug('Created new session for initialize', [ - 'session_id' => $session->getId()->toRfc4122(), - ]); - - $transport->setSessionId($session->getId()); - - return $session; - } - - if (!$sessionId) { - $error = Error::forInvalidRequest('A valid session id is REQUIRED for non-initialize requests.'); - $this->sendResponse($transport, $error, null, ['status_code' => 400]); - - return null; - } - - if (!$this->sessionManager->exists($sessionId)) { - $error = Error::forInvalidRequest('Session not found or has expired.'); - $this->sendResponse($transport, $error, null, ['status_code' => 404]); - - return null; - } - - return $this->sessionManager->createWithId($sessionId); - } - - /** - * Destroy a specific session. - */ - public function destroySession(Uuid $sessionId): void - { - $this->sessionManager->destroy($sessionId); - $this->logger->info('Session destroyed.', ['session_id' => $sessionId->toRfc4122()]); - } -} diff --git a/src/Server/RequestContext.php b/src/Server/RequestContext.php deleted file mode 100644 index 1a4f8375..00000000 --- a/src/Server/RequestContext.php +++ /dev/null @@ -1,93 +0,0 @@ - - */ -final class RequestContext -{ - /** - * `_meta` key carrying the protocol revision of a single request, introduced - * with the modern era that replaced the `initialize` handshake. - * - * @see https://modelcontextprotocol.io/specification/2026-07-28/basic/versioning - */ - private const PROTOCOL_VERSION_META_KEY = 'io.modelcontextprotocol/protocolVersion'; - - private ?ClientGateway $clientGateway = null; - private ?ClientLogger $clientLogger = null; - - public function __construct( - private readonly SessionInterface $session, - private readonly Request $request, - ) { - } - - public function getRequest(): Request - { - return $this->request; - } - - public function getSession(): SessionInterface - { - return $this->session; - } - - /** - * The protocol revision this request is served under. - * - * Modern revisions declare it per request in `_meta`, handshake ones negotiate - * it once and keep it on the session. Neither is guaranteed to be present — a - * transport may skip `initialize` entirely — so this falls back to the newest - * handshake revision, whose rules hold for every revision below it too. - */ - public function getProtocolVersion(): ProtocolVersion - { - $requested = $this->request->getMeta()[self::PROTOCOL_VERSION_META_KEY] - ?? $this->session->get('protocol_version'); - - if (!\is_string($requested)) { - return ProtocolVersion::latestHandshake(); - } - - return ProtocolVersion::tryFrom($requested) ?? ProtocolVersion::latestHandshake(); - } - - public function getClientGateway(): ClientGateway - { - if (null == $this->clientGateway) { - $this->clientGateway = new ClientGateway($this->session); - } - - return $this->clientGateway; - } - - public function getClientLogger(): ClientLogger - { - if (null === $this->clientLogger) { - $this->clientLogger = new ClientLogger($this->getClientGateway(), $this->session); - } - - return $this->clientLogger; - } -} diff --git a/src/Server/Resource/SessionSubscriptionManager.php b/src/Server/Resource/SessionSubscriptionManager.php deleted file mode 100644 index f3ea4d5b..00000000 --- a/src/Server/Resource/SessionSubscriptionManager.php +++ /dev/null @@ -1,94 +0,0 @@ - - */ -final class SessionSubscriptionManager implements SubscriptionManagerInterface -{ - public function __construct( - private readonly LoggerInterface $logger = new NullLogger(), - ) { - } - - /** - * @throws InvalidArgumentException - */ - public function subscribe(SessionInterface $session, string $uri): void - { - $subscriptions = $session->get('resource_subscriptions', []); - $subscriptions[$uri] = true; - $session->set('resource_subscriptions', $subscriptions); - $session->save(); - } - - /** - * @throws InvalidArgumentException - */ - public function unsubscribe(SessionInterface $session, string $uri): void - { - $subscriptions = $session->get('resource_subscriptions', []); - unset($subscriptions[$uri]); - $session->set('resource_subscriptions', $subscriptions); - $session->save(); - } - - /** - * @throws InvalidArgumentException - */ - public function isSubscribed(SessionInterface $session, string $uri): bool - { - $subscriptions = $session->get('resource_subscriptions', []); - - return isset($subscriptions[$uri]); - } - - /** - * @throws InvalidArgumentException - */ - public function notifyResourceChanged(Protocol $protocol, SessionInterface $session, string $uri): void - { - $activeSession = $this->isSubscribed($session, $uri); - if (!$activeSession) { - return; - } - - try { - $protocol->sendNotification( - new ResourceUpdatedNotification($uri), - $session - ); - } catch (InvalidArgumentException $e) { - $this->logger->error('Error sending resource notification to session', [ - 'session_id' => $session->getId()->toRfc4122(), - 'uri' => $uri, - 'exception' => $e, - ]); - - throw $e; - } - } -} diff --git a/src/Server/Resource/SubscriptionManagerInterface.php b/src/Server/Resource/SubscriptionManagerInterface.php deleted file mode 100644 index b31f3d73..00000000 --- a/src/Server/Resource/SubscriptionManagerInterface.php +++ /dev/null @@ -1,53 +0,0 @@ - - */ -interface SubscriptionManagerInterface -{ - /** - * Subscribes a session to a specific resource URI. - * - * @throws InvalidArgumentException - */ - public function subscribe(SessionInterface $session, string $uri): void; - - /** - * Unsubscribes a session from a specific resource URI. - * - * @throws InvalidArgumentException - */ - public function unsubscribe(SessionInterface $session, string $uri): void; - - /** - * Check if a session is subscribed to a resource URI. - * - * @throws InvalidArgumentException - */ - public function isSubscribed(SessionInterface $session, string $uri): bool; - - /** - * Notifies all sessions subscribed to the given resource URI that the - * resource has changed. Sends a ResourceUpdatedNotification for each subscriber. - * - * @throws InvalidArgumentException - */ - public function notifyResourceChanged(Protocol $protocol, SessionInterface $session, string $uri): void; -} diff --git a/src/Server/Session/FileSessionStore.php b/src/Server/Session/FileSessionStore.php deleted file mode 100644 index 9716e3d6..00000000 --- a/src/Server/Session/FileSessionStore.php +++ /dev/null @@ -1,156 +0,0 @@ -directory)) { - @mkdir($this->directory, 0775, true); - } - - if (!is_dir($this->directory) || !is_writable($this->directory)) { - throw new RuntimeException(\sprintf('Session directory "%s" is not writable.', $this->directory)); - } - } - - public function exists(Uuid $id): bool - { - $path = $this->pathFor($id); - - if (!is_file($path)) { - return false; - } - - $mtime = @filemtime($path) ?: 0; - - return ($this->clock->now()->getTimestamp() - $mtime) <= $this->ttl; - } - - public function read(Uuid $id): string|false - { - $path = $this->pathFor($id); - - if (!is_file($path)) { - return false; - } - - $mtime = @filemtime($path) ?: 0; - if (($this->clock->now()->getTimestamp() - $mtime) > $this->ttl) { - @unlink($path); - - return false; - } - - $data = @file_get_contents($path); - if (false === $data) { - return false; - } - - return $data; - } - - public function write(Uuid $id, string $data): bool - { - $path = $this->pathFor($id); - - $tmp = $path.'.tmp'; - if (false === @file_put_contents($tmp, $data, \LOCK_EX)) { - return false; - } - - // Atomic move - if (!@rename($tmp, $path)) { - // Fallback if rename fails cross-device - if (false === @copy($tmp, $path)) { - @unlink($tmp); - - return false; - } - @unlink($tmp); - } - - @touch($path, $this->clock->now()->getTimestamp()); - - return true; - } - - public function destroy(Uuid $id): bool - { - $path = $this->pathFor($id); - - if (is_file($path)) { - @unlink($path); - } - - return true; - } - - /** - * Remove sessions older than the configured TTL. - * Returns an array of deleted session IDs (UUID instances). - */ - public function gc(): array - { - $deleted = []; - $now = $this->clock->now()->getTimestamp(); - - $dir = @opendir($this->directory); - if (false === $dir) { - return $deleted; - } - - while (($entry = readdir($dir)) !== false) { - // Skip dot entries - if ('.' === $entry || '..' === $entry) { - continue; - } - - $path = $this->directory.\DIRECTORY_SEPARATOR.$entry; - if (!is_file($path)) { - continue; - } - - $mtime = @filemtime($path) ?: 0; - if (($now - $mtime) > $this->ttl) { - @unlink($path); - try { - $deleted[] = Uuid::fromString($entry); - } catch (\Throwable) { - // ignore non-UUID file names - } - } - } - - closedir($dir); - - return $deleted; - } - - private function pathFor(Uuid $id): string - { - return $this->directory.\DIRECTORY_SEPARATOR.$id->toRfc4122(); - } -} diff --git a/src/Server/Session/InMemorySessionStore.php b/src/Server/Session/InMemorySessionStore.php deleted file mode 100644 index 04779803..00000000 --- a/src/Server/Session/InMemorySessionStore.php +++ /dev/null @@ -1,88 +0,0 @@ - - */ - protected array $store = []; - - public function __construct( - protected readonly int $ttl = 3600, - protected readonly ClockInterface $clock = new NativeClock(), - ) { - } - - public function exists(Uuid $id): bool - { - return isset($this->store[$id->toRfc4122()]); - } - - public function read(Uuid $id): string|false - { - $session = $this->store[$id->toRfc4122()] ?? ''; - if ('' === $session) { - return false; - } - - $currentTimestamp = $this->clock->now()->getTimestamp(); - - if ($currentTimestamp - $session['timestamp'] > $this->ttl) { - unset($this->store[$id->toRfc4122()]); - - return false; - } - - return $session['data']; - } - - public function write(Uuid $id, string $data): bool - { - $this->store[$id->toRfc4122()] = [ - 'data' => $data, - 'timestamp' => $this->clock->now()->getTimestamp(), - ]; - - return true; - } - - public function destroy(Uuid $id): bool - { - if (isset($this->store[$id->toRfc4122()])) { - unset($this->store[$id->toRfc4122()]); - } - - return true; - } - - public function gc(): array - { - $currentTimestamp = $this->clock->now()->getTimestamp(); - $deletedSessions = []; - - foreach ($this->store as $sessionId => $session) { - $sessionId = Uuid::fromString($sessionId); - if ($currentTimestamp - $session['timestamp'] > $this->ttl) { - unset($this->store[$sessionId->toRfc4122()]); - $deletedSessions[] = $sessionId; - } - } - - return $deletedSessions; - } -} diff --git a/src/Server/Session/Psr16SessionStore.php b/src/Server/Session/Psr16SessionStore.php deleted file mode 100644 index 29ae8ac3..00000000 --- a/src/Server/Session/Psr16SessionStore.php +++ /dev/null @@ -1,79 +0,0 @@ - - */ -class Psr16SessionStore implements SessionStoreInterface -{ - public function __construct( - private readonly CacheInterface $cache, - private readonly string $prefix = 'mcp-', - private readonly int $ttl = 3600, - ) { - } - - public function exists(Uuid $id): bool - { - try { - return $this->cache->has($this->getKey($id)); - } catch (\Throwable) { - return false; - } - } - - public function read(Uuid $id): string|false - { - try { - return $this->cache->get($this->getKey($id), false); - } catch (\Throwable) { - return false; - } - } - - public function write(Uuid $id, string $data): bool - { - try { - return $this->cache->set($this->getKey($id), $data, $this->ttl); - } catch (\Throwable) { - return false; - } - } - - public function destroy(Uuid $id): bool - { - try { - return $this->cache->delete($this->getKey($id)); - } catch (\Throwable) { - return false; - } - } - - public function gc(): array - { - return []; - } - - private function getKey(Uuid $id): string - { - return $this->prefix.$id; - } -} diff --git a/src/Server/Session/Session.php b/src/Server/Session/Session.php deleted file mode 100644 index 6eee72d1..00000000 --- a/src/Server/Session/Session.php +++ /dev/null @@ -1,176 +0,0 @@ - - */ -class Session implements SessionInterface -{ - /** - * Official keys are: - * - initialized: bool - * - client_info: array|null - * - client_capabilities: array|null - * - protocol_version: string|null - * - log_level: string|null - * - * @var array - */ - private array $data; - - public function __construct( - private SessionStoreInterface $store, - private Uuid $id = new UuidV4(), - ) { - } - - public function getId(): Uuid - { - return $this->id; - } - - public function save(): bool - { - return $this->store->write($this->id, json_encode($this->readData(), \JSON_THROW_ON_ERROR)); - } - - public function get(string $key, mixed $default = null): mixed - { - $key = explode('.', $key); - $data = $this->readData(); - - foreach ($key as $segment) { - if (\is_array($data) && \array_key_exists($segment, $data)) { - $data = $data[$segment]; - } else { - return $default; - } - } - - return $data; - } - - public function set(string $key, mixed $value, bool $overwrite = true): void - { - $segments = explode('.', $key); - $this->readData(); - $data = &$this->data; - - while (\count($segments) > 1) { - $segment = array_shift($segments); - if (!isset($data[$segment]) || !\is_array($data[$segment])) { - $data[$segment] = []; - } - $data = &$data[$segment]; - } - - $lastKey = array_shift($segments); - if ($overwrite || !isset($data[$lastKey])) { - $data[$lastKey] = $value; - } - } - - public function has(string $key): bool - { - $key = explode('.', $key); - $data = $this->readData(); - - foreach ($key as $segment) { - if (\is_array($data) && \array_key_exists($segment, $data)) { - $data = $data[$segment]; - } elseif (\is_object($data) && isset($data->{$segment})) { - $data = $data->{$segment}; - } else { - return false; - } - } - - return true; - } - - public function forget(string $key): void - { - $segments = explode('.', $key); - $this->readData(); - $data = &$this->data; - - while (\count($segments) > 1) { - $segment = array_shift($segments); - if (!isset($data[$segment]) || !\is_array($data[$segment])) { - $data[$segment] = []; - } - $data = &$data[$segment]; - } - - $lastKey = array_shift($segments); - if (isset($data[$lastKey])) { - unset($data[$lastKey]); - } - } - - public function clear(): void - { - $this->data = []; - } - - public function pull(string $key, mixed $default = null): mixed - { - $value = $this->get($key, $default); - $this->forget($key); - - return $value; - } - - public function all(): array - { - return $this->readData(); - } - - public function hydrate(array $attributes): void - { - $this->data = $attributes; - } - - /** @return array */ - public function jsonSerialize(): array - { - return $this->all(); - } - - /** - * @return array - */ - private function readData(): array - { - if (isset($this->data)) { - return $this->data; - } - - $rawData = $this->store->read($this->id); - - if (false === $rawData) { - return $this->data = []; - } - - $decoded = json_decode($rawData, true, flags: \JSON_THROW_ON_ERROR); - - if (!\is_array($decoded)) { - return $this->data = []; - } - - return $this->data = $decoded; - } -} diff --git a/src/Server/Session/SessionInterface.php b/src/Server/Session/SessionInterface.php deleted file mode 100644 index e14d2c63..00000000 --- a/src/Server/Session/SessionInterface.php +++ /dev/null @@ -1,80 +0,0 @@ - - */ -interface SessionInterface extends \JsonSerializable -{ - /** - * Get the session ID. - */ - public function getId(): Uuid; - - /** - * Save the session. - */ - public function save(): bool; - - /** - * Get a specific attribute from the session. - * Supports dot notation for nested access. - */ - public function get(string $key, mixed $default = null): mixed; - - /** - * Set a specific attribute in the session. - * Supports dot notation for nested access. - */ - public function set(string $key, mixed $value, bool $overwrite = true): void; - - /** - * Check if an attribute exists in the session. - * Supports dot notation for nested access. - */ - public function has(string $key): bool; - - /** - * Remove an attribute from the session. - * Supports dot notation for nested access. - */ - public function forget(string $key): void; - - /** - * Remove all attributes from the session. - */ - public function clear(): void; - - /** - * Get an attribute's value and then remove it from the session. - * Supports dot notation for nested access. - */ - public function pull(string $key, mixed $default = null): mixed; - - /** - * Get all attributes of the session. - * - * @return array - */ - public function all(): array; - - /** - * Set all attributes of the session, typically for hydration. - * This will overwrite existing attributes. - * - * @param array $attributes - */ - public function hydrate(array $attributes): void; -} diff --git a/src/Server/Session/SessionManager.php b/src/Server/Session/SessionManager.php deleted file mode 100644 index 2a08f7ee..00000000 --- a/src/Server/Session/SessionManager.php +++ /dev/null @@ -1,86 +0,0 @@ - - */ -class SessionManager implements SessionManagerInterface -{ - /** - * @param int $gcProbability The probability (numerator) that GC will run on any given request. Combined with $gcDivisor to calculate the actual probability. Set to 0 to disable GC. Similar to PHP's session.gc_probability. - * @param int $gcDivisor The divisor used with $gcProbability to calculate GC probability. The probability is gcProbability/gcDivisor (e.g. 1/100 = 1%). Similar to PHP's session.gc_divisor. - */ - public function __construct( - private readonly SessionStoreInterface $store, - private readonly LoggerInterface $logger = new NullLogger(), - private readonly int $gcProbability = 1, - private readonly int $gcDivisor = 100, - ) { - if ($gcProbability < 0) { - throw new InvalidArgumentException('gcProbability must be greater than or equal to 0.'); - } - if ($gcDivisor < 1) { - throw new InvalidArgumentException('gcDivisor must be greater than or equal to 1.'); - } - } - - public function create(): SessionInterface - { - return new Session($this->store, Uuid::v4()); - } - - public function createWithId(Uuid $id): SessionInterface - { - return new Session($this->store, $id); - } - - public function exists(Uuid $id): bool - { - return $this->store->exists($id); - } - - public function destroy(Uuid $id): bool - { - return $this->store->destroy($id); - } - - /** - * Run garbage collection on expired sessions. - * Uses the session store's internal TTL configuration. - */ - public function gc(): void - { - if (0 === $this->gcProbability) { - return; - } - - if (random_int(1, $this->gcDivisor) > $this->gcProbability) { - return; - } - - $deletedSessions = $this->store->gc(); - if (!empty($deletedSessions)) { - $this->logger->debug('Garbage collected expired sessions.', [ - 'count' => \count($deletedSessions), - 'session_ids' => array_map(static fn (Uuid $id) => $id->toRfc4122(), $deletedSessions), - ]); - } - } -} diff --git a/src/Server/Session/SessionManagerInterface.php b/src/Server/Session/SessionManagerInterface.php deleted file mode 100644 index dc601f4e..00000000 --- a/src/Server/Session/SessionManagerInterface.php +++ /dev/null @@ -1,47 +0,0 @@ - - */ -interface SessionManagerInterface -{ - /** - * Creates a new session with an auto-generated UUID. - * This is the standard factory method for creating sessions. - */ - public function create(): SessionInterface; - - /** - * Creates a session with a specific UUID. - * Use this when you need to reconstruct a session with a known ID. - */ - public function createWithId(Uuid $id): SessionInterface; - - /** - * Checks if a session with the given UUID exists. - */ - public function exists(Uuid $id): bool; - - /** - * Destroys the session with the given UUID. - */ - public function destroy(Uuid $id): bool; - - public function gc(): void; -} diff --git a/src/Server/Session/SessionStoreInterface.php b/src/Server/Session/SessionStoreInterface.php deleted file mode 100644 index 13f5f161..00000000 --- a/src/Server/Session/SessionStoreInterface.php +++ /dev/null @@ -1,64 +0,0 @@ - - */ -interface SessionStoreInterface -{ - /** - * Check if a session exists. - * - * @param Uuid $id the session id - * - * @return bool true if the session exists, false otherwise - */ - public function exists(Uuid $id): bool; - - /** - * Read session data. - * - * Returns an encoded string of the read data. - * If nothing was read, it must return false. - * - * @param Uuid $id the session id to read data for - */ - public function read(Uuid $id): string|false; - - /** - * Write session data. - * - * @param Uuid $id the session id - * @param string $data the encoded session data - */ - public function write(Uuid $id, string $data): bool; - - /** - * Destroy a session. - * - * @param Uuid $id The session ID being destroyed. - * The return value (usually TRUE on success, FALSE on failure). - */ - public function destroy(Uuid $id): bool; - - /** - * Cleanup old sessions - * Sessions that have not updated for - * the configured TTL will be removed. - * - * @return Uuid[] - */ - public function gc(): array; -} diff --git a/src/Server/Transport/BaseTransport.php b/src/Server/Transport/BaseTransport.php deleted file mode 100644 index 58172352..00000000 --- a/src/Server/Transport/BaseTransport.php +++ /dev/null @@ -1,143 +0,0 @@ - - * - * @author Kyrian Obikwelu - */ -abstract class BaseTransport implements TransportInterface -{ - use ManagesTransportCallbacks; - - protected ?Uuid $sessionId = null; - - /** - * @var McpFiber|null - */ - protected ?\Fiber $sessionFiber = null; - - protected LoggerInterface $logger; - - public function __construct(?LoggerInterface $logger = null) - { - $this->logger = $logger ?? new NullLogger(); - } - - public function initialize(): void - { - } - - public function close(): void - { - } - - public function setSessionId(?Uuid $sessionId): void - { - $this->sessionId = $sessionId; - } - - /** - * @param McpFiber $fiber - */ - public function attachFiberToSession(\Fiber $fiber, Uuid $sessionId): void - { - $this->sessionFiber = $fiber; - $this->sessionId = $sessionId; - } - - /** - * @return array}> - */ - protected function getOutgoingMessages(?Uuid $sessionId): array - { - if ($sessionId && \is_callable($this->outgoingMessagesProvider)) { - return ($this->outgoingMessagesProvider)($sessionId); - } - - return []; - } - - /** - * @return array> - */ - protected function getPendingRequests(?Uuid $sessionId): array - { - if ($sessionId && \is_callable($this->pendingRequestsProvider)) { - return ($this->pendingRequestsProvider)($sessionId); - } - - return []; - } - - /** - * @phpstan-return FiberResume - */ - protected function checkForResponse(int $requestId, ?Uuid $sessionId): Response|Error|null - { - if ($sessionId && \is_callable($this->responseFinder)) { - return ($this->responseFinder)($requestId, $sessionId); - } - - return null; - } - - /** - * @param FiberSuspend|null $yielded - */ - protected function handleFiberYield(mixed $yielded, ?Uuid $sessionId): void - { - if (null === $yielded || !\is_callable($this->fiberYieldHandler)) { - return; - } - - try { - ($this->fiberYieldHandler)($yielded, $sessionId); - } catch (\Throwable $e) { - $this->logger->error('Fiber yield handler failed.', [ - 'exception' => $e, - 'sessionId' => $sessionId?->toRfc4122(), - ]); - } - } - - protected function handleMessage(string $payload, ?Uuid $sessionId): void - { - if (\is_callable($this->messageListener)) { - ($this->messageListener)($this, $payload, $sessionId); - } - } - - protected function handleSessionEnd(?Uuid $sessionId): void - { - if ($sessionId && \is_callable($this->sessionEndListener)) { - ($this->sessionEndListener)($sessionId); - } - } -} diff --git a/src/Server/Transport/CallbackStream.php b/src/Server/Transport/CallbackStream.php deleted file mode 100644 index 72d74b69..00000000 --- a/src/Server/Transport/CallbackStream.php +++ /dev/null @@ -1,157 +0,0 @@ - - */ -final class CallbackStream implements \Stringable, StreamInterface -{ - private bool $called = false; - - private ?\Throwable $exception = null; - - /** - * @param callable(): void $callback The callback to execute when stream is read - */ - public function __construct(private $callback, private LoggerInterface $logger = new NullLogger()) - { - } - - public function __toString(): string - { - try { - $this->invoke(); - } catch (\Throwable $e) { - $this->exception = $e; - $this->logger->error( - \sprintf('CallbackStream execution failed: %s', $e->getMessage()), - ['exception' => $e] - ); - } - - return ''; - } - - public function read($length): string - { - $this->invoke(); - - if (null !== $this->exception) { - throw $this->exception; - } - - return ''; - } - - public function getContents(): string - { - $this->invoke(); - - if (null !== $this->exception) { - throw $this->exception; - } - - return ''; - } - - public function eof(): bool - { - return $this->called; - } - - public function close(): void - { - // No-op - callback-based stream doesn't need closing - } - - public function detach() - { - return null; - } - - public function getSize(): ?int - { - return null; // Unknown size for callback streams - } - - public function tell(): int - { - return 0; - } - - public function isSeekable(): bool - { - return false; - } - - public function seek($offset, $whence = \SEEK_SET): void - { - throw new RuntimeException('Stream is not seekable'); - } - - public function rewind(): void - { - throw new RuntimeException('Stream is not rewindable'); - } - - public function isWritable(): bool - { - return false; - } - - public function write($string): int - { - throw new RuntimeException('Stream is not writable'); - } - - public function isReadable(): bool - { - return !$this->called; - } - - private function invoke(): void - { - if ($this->called) { - return; - } - - $this->called = true; - $this->exception = null; - ($this->callback)(); - } - - public function getMetadata($key = null) - { - return null === $key ? [] : null; - } -} diff --git a/src/Server/Transport/Http/JsonRpcErrorResponse.php b/src/Server/Transport/Http/JsonRpcErrorResponse.php deleted file mode 100644 index 592d1793..00000000 --- a/src/Server/Transport/Http/JsonRpcErrorResponse.php +++ /dev/null @@ -1,41 +0,0 @@ -createResponse($statusCode) - ->withHeader('Content-Type', 'application/json') - ->withBody($streamFactory->createStream($body)); - } -} diff --git a/src/Server/Transport/Http/Middleware/AuthorizationMiddleware.php b/src/Server/Transport/Http/Middleware/AuthorizationMiddleware.php deleted file mode 100644 index d4175985..00000000 --- a/src/Server/Transport/Http/Middleware/AuthorizationMiddleware.php +++ /dev/null @@ -1,182 +0,0 @@ - - */ -final class AuthorizationMiddleware implements MiddlewareInterface -{ - private ResponseFactoryInterface $responseFactory; - - /** - * @param AuthorizationTokenValidatorInterface $validator Token validator implementation - * @param ProtectedResourceMetadata $resourceMetadata Protected resource metadata object used for challenge hints - * @param ResponseFactoryInterface|null $responseFactory PSR-17 response factory (auto-discovered if null) - */ - public function __construct( - private AuthorizationTokenValidatorInterface $validator, - private ProtectedResourceMetadata $resourceMetadata, - ?ResponseFactoryInterface $responseFactory = null, - ) { - $this->responseFactory = $responseFactory ?? Psr17FactoryDiscovery::findResponseFactory(); - } - - public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface - { - $authorization = $request->getHeaderLine('Authorization'); - if ('' === $authorization) { - return $this->buildErrorResponse($request, AuthorizationResult::unauthorized()); - } - - $accessToken = $this->parseBearerToken($authorization); - if (null === $accessToken) { - return $this->buildErrorResponse( - $request, - AuthorizationResult::badRequest('invalid_request', 'Malformed Authorization header.'), - ); - } - - $result = $this->validator->validate($accessToken); - if (!$result->isAllowed()) { - return $this->buildErrorResponse($request, $result); - } - - return $handler->handle($this->applyAttributes($request, $result->getAttributes())); - } - - private function buildErrorResponse(ServerRequestInterface $request, AuthorizationResult $result): ResponseInterface - { - $response = $this->responseFactory->createResponse($result->getStatusCode()); - $header = $this->buildAuthenticateHeader($request, $result); - - $response = $response->withHeader('WWW-Authenticate', $header); - - return $response; - } - - private function buildAuthenticateHeader(ServerRequestInterface $request, AuthorizationResult $result): string - { - $parts = []; - - $parts[] = 'resource_metadata="'.$this->escapeHeaderValue($this->resolveResourceMetadataUrl($request)).'"'; - - $scopes = $this->resolveScopes($result); - if (null !== $scopes) { - $parts[] = 'scope="'.$this->escapeHeaderValue(implode(' ', $scopes)).'"'; - } - - if (null !== $result->getError()) { - $parts[] = 'error="'.$this->escapeHeaderValue($result->getError()).'"'; - } - - if (null !== $result->getErrorDescription()) { - $parts[] = 'error_description="'.$this->escapeHeaderValue($result->getErrorDescription()).'"'; - } - - return 'Bearer '.implode(', ', $parts); - } - - /** - * @return list|null - */ - private function resolveScopes(AuthorizationResult $result): ?array - { - $scopes = $this->normalizeScopes($result->getScopes()); - if (null !== $scopes) { - return $scopes; - } - - return $this->normalizeScopes($this->resourceMetadata->getScopesSupported()); - } - - /** - * @param list|null $scopes - * - * @return list|null - */ - private function normalizeScopes(?array $scopes): ?array - { - if (null === $scopes) { - return null; - } - - $normalized = array_values(array_filter(array_map('trim', $scopes), static function (string $scope): bool { - return '' !== $scope; - })); - - return [] === $normalized ? null : $normalized; - } - - private function resolveResourceMetadataUrl(ServerRequestInterface $request): string - { - $metadataPath = $this->resourceMetadata->getPrimaryMetadataPath(); - - $uri = $request->getUri(); - $scheme = $uri->getScheme(); - $authority = $uri->getAuthority(); - - if ('' === $scheme || '' === $authority) { - throw new RuntimeException('Cannot resolve resource metadata URL: request URI must have scheme and authority'); - } - - return $scheme.'://'.$authority.$metadataPath; - } - - /** - * @param array $attributes - */ - private function applyAttributes(ServerRequestInterface $request, array $attributes): ServerRequestInterface - { - foreach ($attributes as $name => $value) { - $request = $request->withAttribute($name, $value); - } - - return $request; - } - - private function parseBearerToken(string $authorization): ?string - { - if (!preg_match('/^Bearer\\s+(.+)$/i', $authorization, $matches)) { - return null; - } - - $token = trim($matches[1]); - - return '' === $token ? null : $token; - } - - private function escapeHeaderValue(string $value): string - { - return str_replace(['\\', '"'], ['\\\\', '\\"'], $value); - } -} diff --git a/src/Server/Transport/Http/Middleware/ClientRegistrationMiddleware.php b/src/Server/Transport/Http/Middleware/ClientRegistrationMiddleware.php deleted file mode 100644 index 6b94e308..00000000 --- a/src/Server/Transport/Http/Middleware/ClientRegistrationMiddleware.php +++ /dev/null @@ -1,177 +0,0 @@ -responseFactory = $responseFactory ?? Psr17FactoryDiscovery::findResponseFactory(); - $this->streamFactory = $streamFactory ?? Psr17FactoryDiscovery::findStreamFactory(); - } - - public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface - { - $path = $request->getUri()->getPath(); - - if ('POST' === $request->getMethod() && self::REGISTRATION_PATH === $path) { - return $this->handleRegistration($request); - } - - $response = $handler->handle($request); - - if ('GET' === $request->getMethod() && '/.well-known/oauth-authorization-server' === $path) { - return $this->enrichAuthServerMetadata($response); - } - - return $response; - } - - private function handleRegistration(ServerRequestInterface $request): ResponseInterface - { - $contentType = $request->getHeaderLine('Content-Type'); - if (!str_starts_with(strtolower($contentType), 'application/json')) { - return $this->jsonResponse(400, [ - 'error' => 'invalid_client_metadata', - 'error_description' => 'Content-Type must be application/json.', - ], ['Cache-Control' => 'no-store']); - } - - $body = $request->getBody()->__toString(); - - try { - $decoded = json_decode($body, false, 512, \JSON_THROW_ON_ERROR); - } catch (\JsonException) { - return $this->jsonResponse(400, [ - 'error' => 'invalid_client_metadata', - 'error_description' => 'Request body must be valid JSON.', - ], ['Cache-Control' => 'no-store']); - } - - if (!$decoded instanceof \stdClass) { - return $this->jsonResponse(400, [ - 'error' => 'invalid_client_metadata', - 'error_description' => 'Request body must be a JSON object.', - ], ['Cache-Control' => 'no-store']); - } - - // Re-decode with assoc=true so nested objects become arrays (safe — already validated above) - /** @var array $data */ - $data = json_decode($body, true, 512, \JSON_THROW_ON_ERROR); - - try { - $result = $this->registrar->register($data); - } catch (ClientRegistrationException $e) { - return $this->jsonResponse(400, [ - 'error' => $e->errorCode, - 'error_description' => $e->getMessage(), - ], ['Cache-Control' => 'no-store']); - } - - return $this->jsonResponse(201, $result, [ - 'Cache-Control' => 'no-store', - ]); - } - - private function enrichAuthServerMetadata(ResponseInterface $response): ResponseInterface - { - if (200 !== $response->getStatusCode()) { - return $response; - } - - $stream = $response->getBody(); - - if ($stream->isSeekable()) { - $stream->rewind(); - } - - try { - $metadata = json_decode($stream->__toString(), true, 512, \JSON_THROW_ON_ERROR); - } catch (\JsonException) { - if ($stream->isSeekable()) { - $stream->rewind(); - } - - return $response; - } - - if (!\is_array($metadata) || ([] !== $metadata && array_is_list($metadata))) { - if ($stream->isSeekable()) { - $stream->rewind(); - } - - return $response; - } - - $metadata['registration_endpoint'] = rtrim($this->localBaseUrl, '/').self::REGISTRATION_PATH; - - return $response - ->withBody($this->streamFactory->createStream( - json_encode($metadata, \JSON_THROW_ON_ERROR | \JSON_UNESCAPED_SLASHES), - )) - ->withHeader('Content-Type', 'application/json') - ->withoutHeader('Content-Length'); - } - - /** - * @param array $data - * @param array $extraHeaders - */ - private function jsonResponse(int $status, array $data, array $extraHeaders = []): ResponseInterface - { - $response = $this->responseFactory - ->createResponse($status) - ->withHeader('Content-Type', 'application/json') - ->withBody($this->streamFactory->createStream( - json_encode($data, \JSON_THROW_ON_ERROR | \JSON_UNESCAPED_SLASHES), - )); - - foreach ($extraHeaders as $name => $value) { - if ('' !== $value) { - $response = $response->withHeader($name, $value); - } - } - - return $response; - } -} diff --git a/src/Server/Transport/Http/Middleware/CorsMiddleware.php b/src/Server/Transport/Http/Middleware/CorsMiddleware.php deleted file mode 100644 index a32d4ca7..00000000 --- a/src/Server/Transport/Http/Middleware/CorsMiddleware.php +++ /dev/null @@ -1,152 +0,0 @@ - - */ -final class CorsMiddleware implements MiddlewareInterface -{ - private readonly bool $isWildcard; - private readonly bool $varyOnOrigin; - private readonly string $allowedMethodsHeader; - private readonly string $allowedHeadersHeader; - private readonly ?string $exposedHeadersHeader; - - /** - * @param list $allowedOrigins Origins permitted for cross-origin requests. Empty disables `Access-Control-Allow-Origin`. Use `['*']` to allow any origin. - * @param list $allowedMethods Methods advertised via `Access-Control-Allow-Methods` (preflight only) - * @param list $allowedHeaders Headers advertised via `Access-Control-Allow-Headers` (preflight only) - * @param list $exposedHeaders Headers advertised via `Access-Control-Expose-Headers` - * @param bool $allowCredentials Whether to emit `Access-Control-Allow-Credentials: true`. Incompatible with `allowedOrigins: ['*']` — combining them throws. - */ - public function __construct( - private readonly array $allowedOrigins = [], - array $allowedMethods = ['GET', 'POST', 'DELETE'], - array $allowedHeaders = [ - 'Accept', - 'Authorization', - 'Content-Type', - 'Last-Event-ID', - StreamableHttpTransport::PROTOCOL_VERSION_HEADER, - StreamableHttpTransport::SESSION_HEADER, - ], - array $exposedHeaders = [StreamableHttpTransport::SESSION_HEADER], - private readonly bool $allowCredentials = false, - ) { - $this->isWildcard = \in_array('*', $allowedOrigins, true); - - if ($this->isWildcard && $allowCredentials) { - throw new InvalidArgumentException('Access-Control-Allow-Origin: * is incompatible with Access-Control-Allow-Credentials: true. Configure an explicit allowedOrigins list when credentialed requests are required.'); - } - - $this->varyOnOrigin = [] !== $allowedOrigins && !$this->isWildcard; - $this->allowedMethodsHeader = implode(', ', $allowedMethods); - $this->allowedHeadersHeader = implode(', ', $allowedHeaders); - $this->exposedHeadersHeader = [] === $exposedHeaders ? null : implode(', ', $exposedHeaders); - } - - public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface - { - $response = $handler->handle($request); - - $allowedOrigin = $this->resolveAllowedOrigin($request->getHeaderLine('Origin')); - if (null !== $allowedOrigin && !$response->hasHeader('Access-Control-Allow-Origin')) { - $response = $response->withHeader('Access-Control-Allow-Origin', $allowedOrigin); - } - - if ($this->allowCredentials && null !== $allowedOrigin && !$response->hasHeader('Access-Control-Allow-Credentials')) { - $response = $response->withHeader('Access-Control-Allow-Credentials', 'true'); - } - - if ($this->varyOnOrigin) { - $response = $this->ensureVaryOrigin($response); - } - - if ($this->isPreflight($request)) { - if (!$response->hasHeader('Access-Control-Allow-Methods')) { - $response = $response->withHeader('Access-Control-Allow-Methods', $this->allowedMethodsHeader); - } - - if (!$response->hasHeader('Access-Control-Allow-Headers')) { - $response = $response->withHeader('Access-Control-Allow-Headers', $this->allowedHeadersHeader); - } - } - - if (null !== $this->exposedHeadersHeader && !$response->hasHeader('Access-Control-Expose-Headers')) { - $response = $response->withHeader('Access-Control-Expose-Headers', $this->exposedHeadersHeader); - } - - return $response; - } - - private function isPreflight(ServerRequestInterface $request): bool - { - return 'OPTIONS' === $request->getMethod() - && $request->hasHeader('Access-Control-Request-Method'); - } - - private function resolveAllowedOrigin(string $origin): ?string - { - if ([] === $this->allowedOrigins) { - return null; - } - - if ($this->isWildcard) { - return '*'; - } - - if ('' !== $origin && \in_array($origin, $this->allowedOrigins, true)) { - return $origin; - } - - return null; - } - - private function ensureVaryOrigin(ResponseInterface $response): ResponseInterface - { - $current = $response->getHeaderLine('Vary'); - - if ('' === $current) { - return $response->withHeader('Vary', 'Origin'); - } - - if ('*' === trim($current)) { - return $response; - } - - $tokens = array_map('strtolower', array_map('trim', explode(',', $current))); - if (\in_array('origin', $tokens, true)) { - return $response; - } - - return $response->withHeader('Vary', $current.', Origin'); - } -} diff --git a/src/Server/Transport/Http/Middleware/DnsRebindingProtectionMiddleware.php b/src/Server/Transport/Http/Middleware/DnsRebindingProtectionMiddleware.php deleted file mode 100644 index 490aa373..00000000 --- a/src/Server/Transport/Http/Middleware/DnsRebindingProtectionMiddleware.php +++ /dev/null @@ -1,111 +0,0 @@ - - */ -final class DnsRebindingProtectionMiddleware implements MiddlewareInterface -{ - private ResponseFactoryInterface $responseFactory; - private StreamFactoryInterface $streamFactory; - - /** @var list */ - private readonly array $allowedHosts; - - /** - * @param list $allowedHosts Hostnames (without port) that are permitted. Defaults to localhost variants. - * IPv6 addresses must be bracketed (e.g. `[::1]`) — that is the canonical form returned by `parse_url`. - * @param ResponseFactoryInterface|null $responseFactory PSR-17 response factory (auto-discovered if null) - * @param StreamFactoryInterface|null $streamFactory PSR-17 stream factory (auto-discovered if null) - */ - public function __construct( - array $allowedHosts = ['localhost', '127.0.0.1', '[::1]'], - ?ResponseFactoryInterface $responseFactory = null, - ?StreamFactoryInterface $streamFactory = null, - ) { - $this->allowedHosts = array_values(array_map('strtolower', $allowedHosts)); - $this->responseFactory = $responseFactory ?? Psr17FactoryDiscovery::findResponseFactory(); - $this->streamFactory = $streamFactory ?? Psr17FactoryDiscovery::findStreamFactory(); - } - - public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface - { - $origin = $request->getHeaderLine('Origin'); - if ('' !== $origin) { - if (!$this->isAllowedOrigin($origin)) { - return $this->createForbiddenResponse('Forbidden: Invalid Origin header.'); - } - - return $handler->handle($request); - } - - $host = $request->getHeaderLine('Host'); - if ('' !== $host && !$this->isAllowedHost($host)) { - return $this->createForbiddenResponse('Forbidden: Invalid Host header.'); - } - - return $handler->handle($request); - } - - private function isAllowedOrigin(string $origin): bool - { - $host = parse_url($origin, \PHP_URL_HOST); - if (!\is_string($host) || '' === $host) { - return false; - } - - return \in_array(strtolower($host), $this->allowedHosts, true); - } - - private function isAllowedHost(string $host): bool - { - if (str_starts_with($host, '[')) { - $closingBracket = strpos($host, ']'); - if (false === $closingBracket) { - return false; - } - $hostname = substr($host, 0, $closingBracket + 1); - } else { - $hostname = explode(':', $host, 2)[0]; - } - - return \in_array(strtolower($hostname), $this->allowedHosts, true); - } - - private function createForbiddenResponse(string $message): ResponseInterface - { - return $this->responseFactory - ->createResponse(403) - ->withHeader('Content-Type', 'text/plain') - ->withBody($this->streamFactory->createStream($message)); - } -} diff --git a/src/Server/Transport/Http/Middleware/OAuthProxyMiddleware.php b/src/Server/Transport/Http/Middleware/OAuthProxyMiddleware.php deleted file mode 100644 index f59a6dc8..00000000 --- a/src/Server/Transport/Http/Middleware/OAuthProxyMiddleware.php +++ /dev/null @@ -1,268 +0,0 @@ - - */ -final class OAuthProxyMiddleware implements MiddlewareInterface -{ - private const CLIENT_SECRET_BASIC = 'client_secret_basic'; - private const CLIENT_SECRET_POST = 'client_secret_post'; - - private ClientInterface $httpClient; - private RequestFactoryInterface $requestFactory; - private ResponseFactoryInterface $responseFactory; - private StreamFactoryInterface $streamFactory; - - /** - * @param string $upstreamIssuer The issuer URL of the upstream OAuth provider - * @param string $localBaseUrl The base URL of this MCP server (e.g., http://localhost:8000) - * @param string|null $clientSecret Optional client secret for confidential clients - * @param OidcDiscoveryInterface $discovery OIDC discovery provider for upstream metadata - */ - public function __construct( - private readonly string $upstreamIssuer, - private readonly string $localBaseUrl, - private readonly OidcDiscoveryInterface $discovery, - private readonly ?string $clientSecret = null, - ?ClientInterface $httpClient = null, - ?RequestFactoryInterface $requestFactory = null, - ?ResponseFactoryInterface $responseFactory = null, - ?StreamFactoryInterface $streamFactory = null, - ) { - $this->httpClient = $httpClient ?? Psr18ClientDiscovery::find(); - $this->requestFactory = $requestFactory ?? Psr17FactoryDiscovery::findRequestFactory(); - $this->responseFactory = $responseFactory ?? Psr17FactoryDiscovery::findResponseFactory(); - $this->streamFactory = $streamFactory ?? Psr17FactoryDiscovery::findStreamFactory(); - } - - public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface - { - $path = $request->getUri()->getPath(); - - if ('GET' === $request->getMethod() && '/.well-known/oauth-authorization-server' === $path) { - return $this->createAuthServerMetadataResponse(); - } - - if ('GET' === $request->getMethod() && '/authorize' === $path) { - return $this->handleAuthorize($request); - } - - if ('POST' === $request->getMethod() && '/token' === $path) { - return $this->handleToken($request); - } - - return $handler->handle($request); - } - - private function handleAuthorize(ServerRequestInterface $request): ResponseInterface - { - try { - $authorizationEndpoint = $this->discovery->getAuthorizationEndpoint($this->upstreamIssuer); - } catch (RuntimeException) { - return $this->createErrorResponse(500, 'Upstream authorization endpoint not found'); - } - - $rawQueryString = $request->getUri()->getQuery(); - $upstreamUrl = $authorizationEndpoint; - if ('' !== $rawQueryString) { - $upstreamUrl .= '?'.$rawQueryString; - } - - return $this->responseFactory - ->createResponse(302) - ->withHeader('Location', $upstreamUrl) - ->withHeader('Cache-Control', 'no-store'); - } - - private function handleToken(ServerRequestInterface $request): ResponseInterface - { - try { - $tokenEndpoint = $this->discovery->getTokenEndpoint($this->upstreamIssuer); - } catch (RuntimeException) { - return $this->createErrorResponse(500, 'Upstream token endpoint not found'); - } - - $body = $request->getBody()->__toString(); - parse_str($body, $params); - - $upstreamAuthorization = trim($request->getHeaderLine('Authorization')); - if ('' === $upstreamAuthorization) { - $upstreamAuthorization = null; - } - - if (null !== $this->clientSecret && !isset($params['client_secret']) && null === $upstreamAuthorization) { - $authMethod = $this->resolveTokenEndpointAuthMethod(); - - if (self::CLIENT_SECRET_BASIC === $authMethod) { - $clientId = $params['client_id'] ?? null; - - if (\is_string($clientId) && '' !== trim($clientId)) { - $upstreamAuthorization = 'Basic '.base64_encode(trim($clientId).':'.$this->clientSecret); - } else { - $params['client_secret'] = $this->clientSecret; - } - } else { - $params['client_secret'] = $this->clientSecret; - } - } - - $body = http_build_query($params); - - $upstreamRequest = $this->requestFactory - ->createRequest('POST', $tokenEndpoint) - ->withHeader('Content-Type', 'application/x-www-form-urlencoded') - ->withBody($this->streamFactory->createStream($body)); - - if (null !== $upstreamAuthorization) { - $upstreamRequest = $upstreamRequest->withHeader('Authorization', $upstreamAuthorization); - } - - try { - $upstreamResponse = $this->httpClient->sendRequest($upstreamRequest); - $responseBody = $upstreamResponse->getBody()->__toString(); - - return $this->responseFactory - ->createResponse($upstreamResponse->getStatusCode()) - ->withHeader('Content-Type', $upstreamResponse->getHeaderLine('Content-Type')) - ->withHeader('Cache-Control', 'no-store') - ->withBody($this->streamFactory->createStream($responseBody)); - } catch (ClientExceptionInterface $e) { - return $this->createErrorResponse(502, 'Failed to contact upstream token endpoint: '.$e->getMessage()); - } - } - - private function createAuthServerMetadataResponse(): ResponseInterface - { - try { - $upstreamMetadata = $this->discovery->discover($this->upstreamIssuer); - } catch (RuntimeException) { - return $this->createErrorResponse(500, 'Failed to discover upstream server metadata'); - } - - $localBaseUrl = rtrim($this->localBaseUrl, '/'); - $localMetadata = [ - 'issuer' => $localBaseUrl, - 'authorization_endpoint' => $localBaseUrl.'/authorize', - 'token_endpoint' => $localBaseUrl.'/token', - 'response_types_supported' => $upstreamMetadata['response_types_supported'] ?? ['code'], - 'grant_types_supported' => $upstreamMetadata['grant_types_supported'] ?? ['authorization_code', 'refresh_token'], - 'code_challenge_methods_supported' => $upstreamMetadata['code_challenge_methods_supported'] ?? ['S256'], - ]; - - $copyFields = [ - 'scopes_supported', - 'token_endpoint_auth_methods_supported', - 'jwks_uri', - ]; - - foreach ($copyFields as $field) { - if (isset($upstreamMetadata[$field])) { - $localMetadata[$field] = $upstreamMetadata[$field]; - } - } - - return $this->responseFactory - ->createResponse(200) - ->withHeader('Content-Type', 'application/json') - ->withHeader('Cache-Control', 'max-age=3600') - ->withBody($this->streamFactory->createStream(json_encode($localMetadata, \JSON_THROW_ON_ERROR | \JSON_UNESCAPED_SLASHES))); - } - - private function createErrorResponse(int $status, string $message): ResponseInterface - { - $body = json_encode(['error' => 'server_error', 'error_description' => $message]); - - return $this->responseFactory - ->createResponse($status) - ->withHeader('Content-Type', 'application/json') - ->withBody($this->streamFactory->createStream($body)); - } - - private function resolveTokenEndpointAuthMethod(): string - { - $supportedMethods = $this->getTokenEndpointAuthMethods(); - - if (\in_array(self::CLIENT_SECRET_BASIC, $supportedMethods, true)) { - return self::CLIENT_SECRET_BASIC; - } - - if (\in_array(self::CLIENT_SECRET_POST, $supportedMethods, true)) { - return self::CLIENT_SECRET_POST; - } - - return self::CLIENT_SECRET_POST; - } - - /** - * @return list - */ - private function getTokenEndpointAuthMethods(): array - { - try { - $metadata = $this->discovery->discover($this->upstreamIssuer); - } catch (RuntimeException) { - return []; - } - - $methods = $metadata['token_endpoint_auth_methods_supported'] ?? null; - if (!\is_array($methods)) { - return []; - } - - $normalized = []; - foreach ($methods as $method) { - if (!\is_string($method)) { - continue; - } - - $method = trim($method); - if ('' === $method) { - continue; - } - - $normalized[] = $method; - } - - return array_values(array_unique($normalized)); - } -} diff --git a/src/Server/Transport/Http/Middleware/OAuthRequestMetaMiddleware.php b/src/Server/Transport/Http/Middleware/OAuthRequestMetaMiddleware.php deleted file mode 100644 index d1595a58..00000000 --- a/src/Server/Transport/Http/Middleware/OAuthRequestMetaMiddleware.php +++ /dev/null @@ -1,146 +0,0 @@ - - */ -final class OAuthRequestMetaMiddleware implements MiddlewareInterface -{ - private StreamFactoryInterface $streamFactory; - - public function __construct(?StreamFactoryInterface $streamFactory = null) - { - $this->streamFactory = $streamFactory ?? Psr17FactoryDiscovery::findStreamFactory(); - } - - public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface - { - if ('POST' !== $request->getMethod()) { - return $handler->handle($request); - } - - $oauthMeta = $this->extractOAuthAttributes($request); - if ([] === $oauthMeta) { - return $handler->handle($request); - } - - $body = $request->getBody()->__toString(); - if ('' === trim($body)) { - return $handler->handle($request); - } - - try { - $payload = json_decode($body, true, 512, \JSON_THROW_ON_ERROR); - } catch (\JsonException) { - return $handler->handle($request); - } - - $updatedPayload = $this->injectOauthMeta($payload, $oauthMeta); - if (null === $updatedPayload) { - return $handler->handle($request); - } - - try { - $updatedBody = json_encode($updatedPayload, \JSON_THROW_ON_ERROR | \JSON_UNESCAPED_SLASHES); - } catch (\JsonException) { - return $handler->handle($request); - } - - $request = $request->withBody($this->streamFactory->createStream($updatedBody)); - - return $handler->handle($request); - } - - /** - * @return array - */ - private function extractOAuthAttributes(ServerRequestInterface $request): array - { - $result = []; - foreach ($request->getAttributes() as $key => $value) { - if (\is_string($key) && str_starts_with($key, 'oauth.')) { - $result[$key] = $value; - } - } - - return $result; - } - - /** - * @param array $oauthMeta - */ - private function injectOauthMeta(mixed $payload, array $oauthMeta): mixed - { - if (!\is_array($payload)) { - return null; - } - - if (array_is_list($payload)) { - $updated = []; - foreach ($payload as $entry) { - if (!\is_array($entry)) { - $updated[] = $entry; - continue; - } - - $updated[] = $this->injectIntoMessage($entry, $oauthMeta); - } - - return $updated; - } - - return $this->injectIntoMessage($payload, $oauthMeta); - } - - /** - * @param array $message - * @param array $oauthMeta - * - * @return array - */ - private function injectIntoMessage(array $message, array $oauthMeta): array - { - $params = $message['params'] ?? []; - if (!\is_array($params)) { - return $message; - } - - $meta = $params['_meta'] ?? []; - if (!\is_array($meta)) { - $meta = []; - } - - $existingOAuth = $meta['oauth'] ?? []; - if (!\is_array($existingOAuth)) { - $existingOAuth = []; - } - - $meta['oauth'] = array_merge($existingOAuth, $oauthMeta); - $params['_meta'] = $meta; - $message['params'] = $params; - - return $message; - } -} diff --git a/src/Server/Transport/Http/Middleware/ProtectedResourceMetadataMiddleware.php b/src/Server/Transport/Http/Middleware/ProtectedResourceMetadataMiddleware.php deleted file mode 100644 index 67a0cdcc..00000000 --- a/src/Server/Transport/Http/Middleware/ProtectedResourceMetadataMiddleware.php +++ /dev/null @@ -1,64 +0,0 @@ - - */ -final class ProtectedResourceMetadataMiddleware implements MiddlewareInterface -{ - private ProtectedResourceMetadataHandler $metadataHandler; - - public function __construct( - private readonly ProtectedResourceMetadata $metadata, - ?ResponseFactoryInterface $responseFactory = null, - ?StreamFactoryInterface $streamFactory = null, - ) { - $this->metadataHandler = new ProtectedResourceMetadataHandler($metadata, $responseFactory, $streamFactory); - } - - public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface - { - if (!$this->isMetadataRequest($request)) { - return $handler->handle($request); - } - - return $this->metadataHandler->handle($request); - } - - private function isMetadataRequest(ServerRequestInterface $request): bool - { - if ('GET' !== $request->getMethod()) { - return false; - } - - return \in_array($request->getUri()->getPath(), $this->metadata->getMetadataPaths(), true); - } -} diff --git a/src/Server/Transport/Http/Middleware/ProtocolVersionMiddleware.php b/src/Server/Transport/Http/Middleware/ProtocolVersionMiddleware.php deleted file mode 100644 index a4fb76b8..00000000 --- a/src/Server/Transport/Http/Middleware/ProtocolVersionMiddleware.php +++ /dev/null @@ -1,101 +0,0 @@ - - */ -final class ProtocolVersionMiddleware implements MiddlewareInterface -{ - private ResponseFactoryInterface $responseFactory; - private StreamFactoryInterface $streamFactory; - - /** @var list */ - private readonly array $supportedVersions; - - /** - * @param list|null $supportedVersions Versions the server accepts. Defaults to {@see ProtocolVersion::handshakeVersions()}; modern revisions are excluded as their per-request negotiation is not served yet. - * @param ResponseFactoryInterface|null $responseFactory PSR-17 response factory (auto-discovered if null) - * @param StreamFactoryInterface|null $streamFactory PSR-17 stream factory (auto-discovered if null) - */ - public function __construct( - ?array $supportedVersions = null, - ?ResponseFactoryInterface $responseFactory = null, - ?StreamFactoryInterface $streamFactory = null, - ) { - $versions = $supportedVersions ?? ProtocolVersion::handshakeVersions(); - $this->supportedVersions = array_values(array_map(static fn (ProtocolVersion $v): string => $v->value, $versions)); - $this->responseFactory = $responseFactory ?? Psr17FactoryDiscovery::findResponseFactory(); - $this->streamFactory = $streamFactory ?? Psr17FactoryDiscovery::findStreamFactory(); - } - - public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface - { - $headerValue = $request->getHeaderLine(StreamableHttpTransport::PROTOCOL_VERSION_HEADER); - - // Spec backwards-compat: when the header is absent, the server SHOULD assume - // protocol version 2025-03-26 — the release in which Streamable HTTP and the - // header itself were introduced. This is deliberately lower than the SDK's - // own default so clients predating the header convention still get a - // deterministic protocol version applied. Servers that whitelist only newer - // versions in $supportedVersions will reject such requests with 400. - $version = '' === $headerValue ? ProtocolVersion::DEFAULT_HEADER_VERSION->value : $headerValue; - - if (\in_array($version, $this->supportedVersions, true)) { - return $handler->handle($request); - } - - $message = '' === $headerValue - ? \sprintf( - 'Missing %s header; backwards-compat default %s is not accepted. Supported versions: %s.', - StreamableHttpTransport::PROTOCOL_VERSION_HEADER, - $version, - implode(', ', $this->supportedVersions), - ) - : \sprintf( - 'Unsupported %s header value: %s. Supported versions: %s.', - StreamableHttpTransport::PROTOCOL_VERSION_HEADER, - $headerValue, - implode(', ', $this->supportedVersions), - ); - - return JsonRpcErrorResponse::create($this->responseFactory, $this->streamFactory, 400, Error::forInvalidParams($message)); - } -} diff --git a/src/Server/Transport/Http/MiddlewareRequestHandler.php b/src/Server/Transport/Http/MiddlewareRequestHandler.php deleted file mode 100644 index 32ad3725..00000000 --- a/src/Server/Transport/Http/MiddlewareRequestHandler.php +++ /dev/null @@ -1,48 +0,0 @@ - - * - * @internal - */ -final class MiddlewareRequestHandler implements RequestHandlerInterface -{ - private int $index = 0; - - /** - * @param list $middleware - */ - public function __construct( - private readonly array $middleware, - private readonly \Closure $application, - ) { - } - - public function handle(ServerRequestInterface $request): ResponseInterface - { - if (!isset($this->middleware[$this->index])) { - return ($this->application)($request); - } - - return $this->middleware[$this->index++]->process($request, $this); - } -} diff --git a/src/Server/Transport/Http/OAuth/AuthorizationResult.php b/src/Server/Transport/Http/OAuth/AuthorizationResult.php deleted file mode 100644 index 4db13f4c..00000000 --- a/src/Server/Transport/Http/OAuth/AuthorizationResult.php +++ /dev/null @@ -1,135 +0,0 @@ - - */ -final class AuthorizationResult -{ - /** - * @param list|null $scopes Scopes to include in WWW-Authenticate challenge - * @param array $attributes Attributes to attach to the request on success - */ - private function __construct( - private readonly bool $allowed, - private readonly int $statusCode, - private readonly ?string $error, - private readonly ?string $errorDescription, - private readonly ?array $scopes, - private readonly array $attributes, - ) { - } - - /** - * Creates a result indicating access is allowed. - * - * @param array $attributes Attributes to attach to the request (e.g., user_id, scopes) - */ - public static function allow(array $attributes = []): self - { - return new self(true, 200, null, null, null, $attributes); - } - - /** - * Creates a result indicating the request is unauthorized (401). - * - * Use when no valid credentials are provided or the token is invalid. - * - * @param string|null $error OAuth error code (e.g., "invalid_token") - * @param string|null $errorDescription Human-readable error description - * @param list|null $scopes Required scopes to include in challenge - */ - public static function unauthorized( - ?string $error = null, - ?string $errorDescription = null, - ?array $scopes = null, - ): self { - return new self(false, 401, $error, $errorDescription, $scopes, []); - } - - /** - * Creates a result indicating the request is forbidden (403). - * - * Use when the token is valid but lacks required permissions/scopes. - * - * @param string|null $error OAuth error code (defaults to "insufficient_scope") - * @param string|null $errorDescription Human-readable error description - * @param list|null $scopes Required scopes to include in challenge - */ - public static function forbidden( - ?string $error = 'insufficient_scope', - ?string $errorDescription = null, - ?array $scopes = null, - ): self { - return new self(false, 403, $error ?? 'insufficient_scope', $errorDescription, $scopes, []); - } - - /** - * Creates a result indicating a bad request (400). - * - * Use when the Authorization header is malformed. - * - * @param string|null $error OAuth error code (defaults to "invalid_request") - * @param string|null $errorDescription Human-readable error description - */ - public static function badRequest( - ?string $error = 'invalid_request', - ?string $errorDescription = null, - ): self { - return new self(false, 400, $error ?? 'invalid_request', $errorDescription, null, []); - } - - public function isAllowed(): bool - { - return $this->allowed; - } - - public function getStatusCode(): int - { - return $this->statusCode; - } - - public function getError(): ?string - { - return $this->error; - } - - public function getErrorDescription(): ?string - { - return $this->errorDescription; - } - - /** - * @return list|null - */ - public function getScopes(): ?array - { - return $this->scopes; - } - - /** - * @return array - */ - public function getAttributes(): array - { - return $this->attributes; - } -} diff --git a/src/Server/Transport/Http/OAuth/AuthorizationTokenValidatorInterface.php b/src/Server/Transport/Http/OAuth/AuthorizationTokenValidatorInterface.php deleted file mode 100644 index 78b849eb..00000000 --- a/src/Server/Transport/Http/OAuth/AuthorizationTokenValidatorInterface.php +++ /dev/null @@ -1,32 +0,0 @@ - - */ -interface AuthorizationTokenValidatorInterface -{ - /** - * Validates an access token extracted from the Authorization header. - * - * @param string $accessToken The bearer token (without "Bearer " prefix) - * - * @return AuthorizationResult The result of the validation - */ - public function validate(string $accessToken): AuthorizationResult; -} diff --git a/src/Server/Transport/Http/OAuth/ClientRegistrarInterface.php b/src/Server/Transport/Http/OAuth/ClientRegistrarInterface.php deleted file mode 100644 index c0b58dcd..00000000 --- a/src/Server/Transport/Http/OAuth/ClientRegistrarInterface.php +++ /dev/null @@ -1,44 +0,0 @@ - $registrationRequest Client metadata from the registration request body - * - * @return array Registration response including client_id and optional client_secret - * - * @throws ClientRegistrationException If registration fails (e.g. invalid metadata, storage error). - * The exception message is returned to the client as error_description — - * do not include internal details (database errors, stack traces, etc.). - */ - public function register(array $registrationRequest): array; -} diff --git a/src/Server/Transport/Http/OAuth/JwksProvider.php b/src/Server/Transport/Http/OAuth/JwksProvider.php deleted file mode 100644 index 794ce3b3..00000000 --- a/src/Server/Transport/Http/OAuth/JwksProvider.php +++ /dev/null @@ -1,122 +0,0 @@ - - */ -class JwksProvider implements JwksProviderInterface -{ - private const CACHE_KEY_PREFIX = 'mcp_jwks_'; - - private ClientInterface $httpClient; - private RequestFactoryInterface $requestFactory; - - /** - * @param OidcDiscoveryInterface $discovery OIDC discovery provider (required for JWKS URI resolution when $jwksUri is not explicit) - * @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 CacheInterface|null $cache Optional PSR-16 cache - * @param int $cacheTtl JWKS cache TTL in seconds - */ - public function __construct( - private readonly OidcDiscoveryInterface $discovery, - ?ClientInterface $httpClient = null, - ?RequestFactoryInterface $requestFactory = null, - private readonly ?CacheInterface $cache = null, - private readonly int $cacheTtl = 3600, - ) { - $this->httpClient = $httpClient ?? Psr18ClientDiscovery::find(); - $this->requestFactory = $requestFactory ?? Psr17FactoryDiscovery::findRequestFactory(); - } - - /** - * @return array - */ - public function getJwks(string $issuer, ?string $jwksUri = null): array - { - $jwksUri ??= $this->discovery->getJwksUri($issuer); - $cacheKey = self::CACHE_KEY_PREFIX.hash('sha256', $jwksUri); - - if (null !== $this->cache) { - $cached = $this->cache->get($cacheKey); - if ($this->isJwksValid($cached)) { - /* @var array $cached */ - return $cached; - } - } - - $jwks = $this->fetchJwks($jwksUri); - - if (!$this->isJwksValid($jwks)) { - throw new RuntimeException(\sprintf('JWKS response from %s has invalid format: expected non-empty "keys" array.', $jwksUri)); - } - - if (null !== $this->cache) { - $this->cache->set($cacheKey, $jwks, $this->cacheTtl); - } - - return $jwks; - } - - /** - * @return array - */ - private function fetchJwks(string $jwksUri): array - { - $request = $this->requestFactory->createRequest('GET', $jwksUri) - ->withHeader('Accept', 'application/json'); - - try { - $response = $this->httpClient->sendRequest($request); - } catch (ClientExceptionInterface $e) { - throw new RuntimeException(\sprintf('Failed to fetch JWKS from %s: %s', $jwksUri, $e->getMessage()), 0, $e); - } - - if (200 !== $response->getStatusCode()) { - throw new RuntimeException(\sprintf('Failed to fetch JWKS from %s: HTTP %d', $jwksUri, $response->getStatusCode())); - } - - try { - $data = json_decode($response->getBody()->__toString(), true, 512, \JSON_THROW_ON_ERROR); - } catch (\JsonException $e) { - throw new RuntimeException(\sprintf('Failed to decode JWKS: %s', $e->getMessage()), 0, $e); - } - - if (!\is_array($data)) { - throw new RuntimeException('Invalid JWKS format: expected JSON object.'); - } - - return $data; - } - - private function isJwksValid(mixed $jwks): bool - { - if (!\is_array($jwks) || !isset($jwks['keys']) || !\is_array($jwks['keys'])) { - return false; - } - - $nonEmptyKeys = array_filter($jwks['keys'], static fn (mixed $key): bool => \is_array($key) && [] !== $key); - - return [] !== $nonEmptyKeys; - } -} diff --git a/src/Server/Transport/Http/OAuth/JwksProviderInterface.php b/src/Server/Transport/Http/OAuth/JwksProviderInterface.php deleted file mode 100644 index f33ac658..00000000 --- a/src/Server/Transport/Http/OAuth/JwksProviderInterface.php +++ /dev/null @@ -1,28 +0,0 @@ - - */ -interface JwksProviderInterface -{ - /** - * @param string $issuer authorization server issuer URL - * @param string|null $jwksUri Optional explicit JWKS URI. If null, implementation may resolve via discovery. - * - * @return array - */ - public function getJwks(string $issuer, ?string $jwksUri = null): array; -} diff --git a/src/Server/Transport/Http/OAuth/JwtTokenValidator.php b/src/Server/Transport/Http/OAuth/JwtTokenValidator.php deleted file mode 100644 index a0ffe061..00000000 --- a/src/Server/Transport/Http/OAuth/JwtTokenValidator.php +++ /dev/null @@ -1,205 +0,0 @@ - - */ -class JwtTokenValidator implements AuthorizationTokenValidatorInterface -{ - /** - * @param string|list $issuer Expected token issuer(s) (e.g., "https://auth.example.com/realms/mcp") - * @param string|list $audience Expected audience(s) for the token - * @param JwksProviderInterface $jwksProvider JWKS provider - * @param string|null $jwksUri Explicit JWKS URI (auto-discovered from first issuer if null) - * @param list $algorithms Allowed JWT algorithms (default: RS256, RS384, RS512) - * @param string $scopeClaim Claim name for scopes (default: "scope") - */ - public function __construct( - private readonly string|array $issuer, - private readonly string|array $audience, - private readonly JwksProviderInterface $jwksProvider, - private readonly ?string $jwksUri = null, - private readonly array $algorithms = ['RS256', 'RS384', 'RS512'], - private readonly string $scopeClaim = 'scope', - ) { - if (!class_exists(JWT::class)) { - throw new RuntimeException('For using the JwtTokenValidator, the firebase/php-jwt package is required. Try running "composer require firebase/php-jwt".'); - } - } - - public function validate(string $accessToken): AuthorizationResult - { - try { - /** @var array $claims */ - $claims = (array) JWT::decode($accessToken, $this->getJwks()); - - // Validate issuer - if (!$this->validateIssuer($claims)) { - return AuthorizationResult::unauthorized('invalid_token', 'Token issuer mismatch.'); - } - - // Validate audience - if (!$this->validateAudience($claims)) { - return AuthorizationResult::unauthorized('invalid_token', 'Token audience mismatch.'); - } - - // Build attributes to attach to request - $attributes = [ - 'oauth.claims' => $claims, - 'oauth.scopes' => $this->extractScopes($claims), - ]; - - // Add common claims as individual attributes - if (isset($claims['sub'])) { - $attributes['oauth.subject'] = $claims['sub']; - } - - if (isset($claims['client_id'])) { - $attributes['oauth.client_id'] = $claims['client_id']; - } - - // Add azp (authorized party) for OIDC tokens - if (isset($claims['azp'])) { - $attributes['oauth.authorized_party'] = $claims['azp']; - } - - return AuthorizationResult::allow($attributes); - } catch (ExpiredException) { - return AuthorizationResult::unauthorized('invalid_token', 'Token has expired.'); - } catch (SignatureInvalidException) { - return AuthorizationResult::unauthorized('invalid_token', 'Token signature verification failed.'); - } catch (BeforeValidException) { - return AuthorizationResult::unauthorized('invalid_token', 'Token is not yet valid.'); - } catch (\InvalidArgumentException|\UnexpectedValueException|\DomainException $e) { - return AuthorizationResult::unauthorized('invalid_token', 'Token validation failed: '.$e->getMessage()); - } - } - - /** - * 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 - { - if (!$result->isAllowed()) { - return $result; - } - - $tokenScopes = $result->getAttributes()['oauth.scopes'] ?? []; - - if (!\is_array($tokenScopes)) { - $tokenScopes = []; - } - - foreach ($requiredScopes as $required) { - if (!\in_array($required, $tokenScopes, true)) { - return AuthorizationResult::forbidden('insufficient_scope', \sprintf('Required scope: %s', $required), $requiredScopes); - } - } - - return $result; - } - - /** - * @return array - */ - private function getJwks(): array - { - $issuer = \is_array($this->issuer) ? $this->issuer[0] : $this->issuer; - $jwksData = $this->jwksProvider->getJwks($issuer, $this->jwksUri); - - /* @var array */ - return JWK::parseKeySet($jwksData, $this->algorithms[0]); - } - - /** - * @param array $claims - */ - private function validateAudience(array $claims): bool - { - if (!isset($claims['aud'])) { - return false; - } - - $tokenAudiences = \is_array($claims['aud']) ? $claims['aud'] : [$claims['aud']]; - $expectedAudiences = \is_array($this->audience) ? $this->audience : [$this->audience]; - - foreach ($expectedAudiences as $expected) { - if (\in_array($expected, $tokenAudiences, true)) { - return true; - } - } - - return false; - } - - /** - * @param array $claims - */ - private function validateIssuer(array $claims): bool - { - if (!isset($claims['iss'])) { - return false; - } - - $expectedIssuers = \is_array($this->issuer) ? $this->issuer : [$this->issuer]; - - return \in_array($claims['iss'], $expectedIssuers, 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 []; - } -} diff --git a/src/Server/Transport/Http/OAuth/LenientOidcDiscoveryMetadataPolicy.php b/src/Server/Transport/Http/OAuth/LenientOidcDiscoveryMetadataPolicy.php deleted file mode 100644 index 2a8c507b..00000000 --- a/src/Server/Transport/Http/OAuth/LenientOidcDiscoveryMetadataPolicy.php +++ /dev/null @@ -1,54 +0,0 @@ - - */ -final class LenientOidcDiscoveryMetadataPolicy implements OidcDiscoveryMetadataPolicyInterface -{ - public function isValid(mixed $metadata): bool - { - if (!\is_array($metadata) - || !isset($metadata['authorization_endpoint'], $metadata['token_endpoint'], $metadata['jwks_uri']) - || !\is_string($metadata['authorization_endpoint']) - || '' === trim($metadata['authorization_endpoint']) - || !\is_string($metadata['token_endpoint']) - || '' === trim($metadata['token_endpoint']) - || !\is_string($metadata['jwks_uri']) - || '' === trim($metadata['jwks_uri']) - ) { - return false; - } - - if (\array_key_exists('code_challenge_methods_supported', $metadata)) { - if (!\is_array($metadata['code_challenge_methods_supported']) || [] === $metadata['code_challenge_methods_supported']) { - return false; - } - - foreach ($metadata['code_challenge_methods_supported'] as $method) { - if (!\is_string($method) || '' === trim($method)) { - return false; - } - } - } - - return true; - } -} diff --git a/src/Server/Transport/Http/OAuth/OidcDiscovery.php b/src/Server/Transport/Http/OAuth/OidcDiscovery.php deleted file mode 100644 index 2f246852..00000000 --- a/src/Server/Transport/Http/OAuth/OidcDiscovery.php +++ /dev/null @@ -1,236 +0,0 @@ - - */ -class OidcDiscovery implements OidcDiscoveryInterface -{ - private const CACHE_KEY_PREFIX = 'mcp_oidc_discovery_'; - - private ClientInterface $httpClient; - private RequestFactoryInterface $requestFactory; - private OidcDiscoveryMetadataPolicyInterface $metadataPolicy; - - /** - * @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 CacheInterface|null $cache PSR-16 cache for metadata (optional) - * @param int $cacheTtl Cache TTL in seconds (default: 1 hour) - * @param OidcDiscoveryMetadataPolicyInterface|null $metadataPolicy Metadata validation policy - */ - public function __construct( - ?ClientInterface $httpClient = null, - ?RequestFactoryInterface $requestFactory = null, - private readonly ?CacheInterface $cache = null, - private readonly int $cacheTtl = 3600, - ?OidcDiscoveryMetadataPolicyInterface $metadataPolicy = null, - ) { - $this->httpClient = $httpClient ?? Psr18ClientDiscovery::find(); - $this->requestFactory = $requestFactory ?? Psr17FactoryDiscovery::findRequestFactory(); - $this->metadataPolicy = $metadataPolicy ?? new StrictOidcDiscoveryMetadataPolicy(); - } - - /** - * Gets the JWKS URI from the authorization server metadata. - * - * @param string $issuer The issuer URL - * - * @return string The JWKS URI - * - * @throws RuntimeException If discover fails - */ - public function getJwksUri(string $issuer): string - { - $metadata = $this->discover($issuer); - - return $metadata['jwks_uri']; - } - - /** - * Gets the token endpoint from the authorization server metadata. - * - * @param string $issuer The issuer URL - * - * @return string The token endpoint URL - * - * @throws RuntimeException If discover fails - */ - public function getTokenEndpoint(string $issuer): string - { - $metadata = $this->discover($issuer); - - return $metadata['token_endpoint']; - } - - /** - * Gets the authorization endpoint from the authorization server metadata. - * - * @param string $issuer The issuer URL - * - * @return string The authorization endpoint URL - * - * @throws RuntimeException If discover fails - */ - public function getAuthorizationEndpoint(string $issuer): string - { - $metadata = $this->discover($issuer); - - return $metadata['authorization_endpoint']; - } - - /** - * Discovers authorization server metadata from the issuer URL. - * - * Tries endpoints in priority order per RFC 8414 and OpenID Connect Discovery: - * 1. OAuth 2.0 path insertion: /.well-known/oauth-authorization-server/{path} - * 2. OIDC path insertion: /.well-known/openid-configuration/{path} - * 3. OIDC path appending: {path}/.well-known/openid-configuration - * - * @param string $issuer The issuer URL (e.g., "https://auth.example.com/realms/mcp") - * - * @return array The authorization server metadata - * - * @throws RuntimeException If discovery fails - */ - public function discover(string $issuer): array - { - $cacheKey = self::CACHE_KEY_PREFIX.hash('sha256', $issuer); - - if (null !== $this->cache) { - $cached = $this->cache->get($cacheKey); - if (\is_array($cached)) { - /* @var array $cached */ - return $cached; - } - } - - $metadata = $this->fetchMetadata($issuer); - - if (null !== $this->cache) { - $this->cache->set($cacheKey, $metadata, $this->cacheTtl); - } - - return $metadata; - } - - /** - * @return array - */ - private function fetchMetadata(string $issuer): array - { - $issuer = rtrim($issuer, '/'); - $parsed = parse_url($issuer); - - if (false === $parsed || !isset($parsed['scheme'], $parsed['host'])) { - throw new RuntimeException(\sprintf('Invalid issuer URL: %s', $issuer)); - } - - $scheme = $parsed['scheme']; - $host = $parsed['host']; - $port = isset($parsed['port']) ? ':'.$parsed['port'] : ''; - $path = $parsed['path'] ?? ''; - - $baseUrl = $scheme.'://'.$host.$port; - - // Build discovery URLs in priority order per RFC 8414 Section 3.1 - $discoveryUrls = []; - - if ('' !== $path && '/' !== $path) { - // For issuer URLs with path components - // 1. OAuth 2.0 path insertion - $discoveryUrls[] = $baseUrl.'/.well-known/oauth-authorization-server'.$path; - // 2. OIDC path insertion - $discoveryUrls[] = $baseUrl.'/.well-known/openid-configuration'.$path; - // 3. OIDC path appending - $discoveryUrls[] = $issuer.'/.well-known/openid-configuration'; - } else { - // For issuer URLs without path components - $discoveryUrls[] = $baseUrl.'/.well-known/oauth-authorization-server'; - $discoveryUrls[] = $baseUrl.'/.well-known/openid-configuration'; - } - - $lastException = null; - - foreach ($discoveryUrls as $url) { - try { - $metadata = $this->fetchJson($url); - if (!$this->metadataPolicy->isValid($metadata)) { - throw new RuntimeException(\sprintf('OIDC discovery response from %s has invalid format.', $url)); - } - - if (!isset($metadata['issuer']) || !\is_string($metadata['issuer'])) { - throw new RuntimeException(\sprintf('OIDC discovery response from %s is missing required "issuer" field.', $url)); - } - if ($metadata['issuer'] !== $issuer) { - throw new RuntimeException(\sprintf('OIDC discovery issuer mismatch for %s: expected %s, got %s.', $url, $issuer, $metadata['issuer'])); - } - - return $metadata; - } catch (RuntimeException $e) { - $lastException = $e; - continue; - } - } - - throw new RuntimeException(\sprintf('Failed to discover authorization server metadata for issuer: %s', $issuer), 0, $lastException); - } - - /** - * @return array - */ - private function fetchJson(string $url): array - { - $request = $this->requestFactory->createRequest('GET', $url) - ->withHeader('Accept', 'application/json'); - - try { - $response = $this->httpClient->sendRequest($request); - } catch (ClientExceptionInterface $e) { - throw new RuntimeException(\sprintf('HTTP request to %s failed: %s', $url, $e->getMessage()), 0, $e); - } - - if (200 !== $response->getStatusCode()) { - throw new RuntimeException(\sprintf('HTTP request to %s failed with status %d', $url, $response->getStatusCode())); - } - - try { - $data = json_decode($response->getBody()->__toString(), true, 512, \JSON_THROW_ON_ERROR); - } catch (\JsonException $e) { - throw new RuntimeException(\sprintf('Failed to decode JSON from %s: %s', $url, $e->getMessage()), 0, $e); - } - - if (!\is_array($data)) { - throw new RuntimeException(\sprintf('Expected JSON object from %s, got %s', $url, \gettype($data))); - } - - return $data; - } -} diff --git a/src/Server/Transport/Http/OAuth/OidcDiscoveryInterface.php b/src/Server/Transport/Http/OAuth/OidcDiscoveryInterface.php deleted file mode 100644 index aeabf0bb..00000000 --- a/src/Server/Transport/Http/OAuth/OidcDiscoveryInterface.php +++ /dev/null @@ -1,31 +0,0 @@ - - */ -interface OidcDiscoveryInterface -{ - /** - * @return array - */ - public function discover(string $issuer): array; - - public function getAuthorizationEndpoint(string $issuer): string; - - public function getTokenEndpoint(string $issuer): string; - - public function getJwksUri(string $issuer): string; -} diff --git a/src/Server/Transport/Http/OAuth/OidcDiscoveryMetadataPolicyInterface.php b/src/Server/Transport/Http/OAuth/OidcDiscoveryMetadataPolicyInterface.php deleted file mode 100644 index edf94c48..00000000 --- a/src/Server/Transport/Http/OAuth/OidcDiscoveryMetadataPolicyInterface.php +++ /dev/null @@ -1,22 +0,0 @@ - - */ -interface OidcDiscoveryMetadataPolicyInterface -{ - public function isValid(mixed $metadata): bool; -} diff --git a/src/Server/Transport/Http/OAuth/ProtectedResourceMetadata.php b/src/Server/Transport/Http/OAuth/ProtectedResourceMetadata.php deleted file mode 100644 index dd96982b..00000000 --- a/src/Server/Transport/Http/OAuth/ProtectedResourceMetadata.php +++ /dev/null @@ -1,245 +0,0 @@ - - */ -final class ProtectedResourceMetadata implements \JsonSerializable -{ - public const DEFAULT_METADATA_PATH = '/.well-known/oauth-protected-resource'; - - private const LOCALIZED_HUMAN_READABLE_FIELD_PATTERN = '/^(resource_name|resource_documentation|resource_policy_uri|resource_tos_uri)#[A-Za-z0-9-]+$/'; - - /** @var list */ - private array $authorizationServers; - - /** @var list|null */ - private ?array $scopesSupported; - - /** @var list */ - private array $metadataPaths; - - /** @var array */ - private array $localizedHumanReadable; - - /** @var array */ - private array $extra; - - private ?string $resource; - private ?string $resourceName; - private ?string $resourceDocumentation; - private ?string $resourcePolicyUri; - private ?string $resourceTosUri; - - /** - * @param list $authorizationServers - * @param list|null $scopesSupported - * @param array $localizedHumanReadable Locale-specific values, e.g. resource_name#en => "My Resource" - * @param array $extra Additional RFC 9728 metadata fields - * @param list $metadataPaths - */ - public function __construct( - array $authorizationServers, - ?array $scopesSupported = null, - ?string $resource = null, - ?string $resourceName = null, - ?string $resourceDocumentation = null, - ?string $resourcePolicyUri = null, - ?string $resourceTosUri = null, - array $localizedHumanReadable = [], - array $extra = [], - array $metadataPaths = [self::DEFAULT_METADATA_PATH], - ) { - $this->authorizationServers = $this->normalizeStringList($authorizationServers, 'authorizationServers'); - if ([] === $this->authorizationServers) { - throw new InvalidArgumentException('Protected resource metadata requires at least one authorization server.'); - } - - $normalizedScopes = $this->normalizeStringList($scopesSupported ?? [], 'scopesSupported'); - $this->scopesSupported = [] === $normalizedScopes ? null : $normalizedScopes; - - $this->resource = $this->normalizeNullableString($resource); - $this->resourceName = $this->normalizeNullableString($resourceName); - $this->resourceDocumentation = $this->normalizeNullableString($resourceDocumentation); - $this->resourcePolicyUri = $this->normalizeNullableString($resourcePolicyUri); - $this->resourceTosUri = $this->normalizeNullableString($resourceTosUri); - $this->localizedHumanReadable = $this->normalizeLocalizedHumanReadable($localizedHumanReadable); - $this->extra = $extra; - - $this->metadataPaths = $this->normalizePaths($metadataPaths); - if ([] === $this->metadataPaths) { - throw new InvalidArgumentException('Protected resource metadata requires at least one metadata path.'); - } - } - - /** - * @return list - */ - public function getMetadataPaths(): array - { - return $this->metadataPaths; - } - - public function getPrimaryMetadataPath(): string - { - return $this->metadataPaths[0]; - } - - /** - * @return list|null - */ - public function getScopesSupported(): ?array - { - return $this->scopesSupported; - } - - /** - * @return array - */ - public function jsonSerialize(): array - { - $data = [ - 'authorization_servers' => $this->authorizationServers, - ]; - - if (null !== $this->scopesSupported) { - $data['scopes_supported'] = $this->scopesSupported; - } - - if (null !== $this->resource) { - $data['resource'] = $this->resource; - } - - if (null !== $this->resourceName) { - $data['resource_name'] = $this->resourceName; - } - - if (null !== $this->resourceDocumentation) { - $data['resource_documentation'] = $this->resourceDocumentation; - } - - if (null !== $this->resourcePolicyUri) { - $data['resource_policy_uri'] = $this->resourcePolicyUri; - } - - if (null !== $this->resourceTosUri) { - $data['resource_tos_uri'] = $this->resourceTosUri; - } - - foreach ($this->localizedHumanReadable as $key => $value) { - $data[$key] = $value; - } - - return array_merge($this->extra, $data); - } - - /** - * @param list $values - * - * @return list - */ - private function normalizeStringList(array $values, string $parameterName): array - { - $normalized = []; - - foreach ($values as $value) { - if (!\is_string($value)) { - throw new InvalidArgumentException(\sprintf('Protected resource metadata parameter "%s" must contain strings.', $parameterName)); - } - - $value = trim($value); - if ('' === $value) { - continue; - } - - $normalized[] = $value; - } - - return array_values(array_unique($normalized)); - } - - private function normalizeNullableString(?string $value): ?string - { - if (null === $value) { - return null; - } - - $value = trim($value); - - return '' === $value ? null : $value; - } - - /** - * @param list $paths - * - * @return list - */ - private function normalizePaths(array $paths): array - { - $normalized = []; - - foreach ($paths as $path) { - if (!\is_string($path)) { - throw new InvalidArgumentException('Protected resource metadata paths must be strings.'); - } - - $path = trim($path); - if ('' === $path) { - continue; - } - - if ('/' !== $path[0]) { - $path = '/'.$path; - } - - $normalized[] = $path; - } - - return array_values(array_unique($normalized)); - } - - /** - * @param array $localizedHumanReadable - * - * @return array - */ - private function normalizeLocalizedHumanReadable(array $localizedHumanReadable): array - { - $normalized = []; - - foreach ($localizedHumanReadable as $field => $value) { - if (!\is_string($field) || !preg_match(self::LOCALIZED_HUMAN_READABLE_FIELD_PATTERN, $field)) { - throw new InvalidArgumentException(\sprintf('Invalid localized human-readable field: "%s".', (string) $field)); - } - - if (!\is_string($value)) { - throw new InvalidArgumentException(\sprintf('Localized human-readable value for "%s" must be a string.', $field)); - } - - $value = trim($value); - if ('' === $value) { - continue; - } - - $normalized[$field] = $value; - } - - return $normalized; - } -} diff --git a/src/Server/Transport/Http/OAuth/ProtectedResourceMetadataHandler.php b/src/Server/Transport/Http/OAuth/ProtectedResourceMetadataHandler.php deleted file mode 100644 index 0b0cf7f2..00000000 --- a/src/Server/Transport/Http/OAuth/ProtectedResourceMetadataHandler.php +++ /dev/null @@ -1,60 +0,0 @@ - - */ -final class ProtectedResourceMetadataHandler implements RequestHandlerInterface -{ - private ResponseFactoryInterface $responseFactory; - private StreamFactoryInterface $streamFactory; - - public function __construct( - private readonly ProtectedResourceMetadata $metadata, - ?ResponseFactoryInterface $responseFactory = null, - ?StreamFactoryInterface $streamFactory = null, - ) { - $this->responseFactory = $responseFactory ?? Psr17FactoryDiscovery::findResponseFactory(); - $this->streamFactory = $streamFactory ?? Psr17FactoryDiscovery::findStreamFactory(); - } - - public function handle(ServerRequestInterface $request): ResponseInterface - { - return $this->responseFactory - ->createResponse(200) - ->withHeader('Content-Type', 'application/json') - ->withBody($this->streamFactory->createStream(json_encode($this->metadata, \JSON_THROW_ON_ERROR | \JSON_UNESCAPED_SLASHES))); - } -} diff --git a/src/Server/Transport/Http/OAuth/StrictOidcDiscoveryMetadataPolicy.php b/src/Server/Transport/Http/OAuth/StrictOidcDiscoveryMetadataPolicy.php deleted file mode 100644 index b89a3f8a..00000000 --- a/src/Server/Transport/Http/OAuth/StrictOidcDiscoveryMetadataPolicy.php +++ /dev/null @@ -1,48 +0,0 @@ - - */ -final class StrictOidcDiscoveryMetadataPolicy implements OidcDiscoveryMetadataPolicyInterface -{ - public function isValid(mixed $metadata): bool - { - if (!\is_array($metadata) - || !isset($metadata['authorization_endpoint'], $metadata['token_endpoint'], $metadata['jwks_uri']) - || !\is_string($metadata['authorization_endpoint']) - || '' === trim($metadata['authorization_endpoint']) - || !\is_string($metadata['token_endpoint']) - || '' === trim($metadata['token_endpoint']) - || !\is_string($metadata['jwks_uri']) - || '' === trim($metadata['jwks_uri']) - || !isset($metadata['code_challenge_methods_supported']) - ) { - return false; - } - - if (!\is_array($metadata['code_challenge_methods_supported']) || [] === $metadata['code_challenge_methods_supported']) { - return false; - } - - foreach ($metadata['code_challenge_methods_supported'] as $method) { - if (!\is_string($method) || '' === trim($method)) { - return false; - } - } - - return true; - } -} diff --git a/src/Server/Transport/InMemoryTransport.php b/src/Server/Transport/InMemoryTransport.php deleted file mode 100644 index ddc6ca12..00000000 --- a/src/Server/Transport/InMemoryTransport.php +++ /dev/null @@ -1,75 +0,0 @@ - - * - * @author Tobias Nyholm - */ -class InMemoryTransport extends BaseTransport -{ - /** - * @param list $messages - */ - public function __construct( - private readonly array $messages = [], - ?LoggerInterface $logger = null, - ) { - parent::__construct($logger); - } - - public function onMessage(callable $listener): void - { - $this->messageListener = $listener; - } - - public function send(string $data, array $context): void - { - if (isset($context['session_id'])) { - $this->sessionId = $context['session_id']; - } - } - - /** - * @return null - */ - public function listen(): mixed - { - $this->logger->info('InMemoryTransport is processing messages...'); - - foreach ($this->messages as $message) { - $this->handleMessage($message, $this->sessionId); - } - - $this->logger->info('InMemoryTransport finished processing.'); - $this->handleSessionEnd($this->sessionId); - - $this->sessionId = null; - - return null; - } - - public function setSessionId(?Uuid $sessionId): void - { - $this->sessionId = $sessionId; - } - - public function close(): void - { - $this->handleSessionEnd($this->sessionId); - $this->sessionId = null; - } -} diff --git a/src/Server/Transport/ManagesTransportCallbacks.php b/src/Server/Transport/ManagesTransportCallbacks.php deleted file mode 100644 index 072d3f0e..00000000 --- a/src/Server/Transport/ManagesTransportCallbacks.php +++ /dev/null @@ -1,82 +0,0 @@ - - * */ -trait ManagesTransportCallbacks -{ - /** @var callable(TransportInterface, string, ?Uuid): void */ - protected $messageListener; - - /** @var callable(Uuid): void */ - protected $sessionEndListener; - - /** @var callable(Uuid): array}> */ - protected $outgoingMessagesProvider; - - /** @var callable(Uuid): array> */ - protected $pendingRequestsProvider; - - /** @var callable(int, Uuid): Response>|Error|null */ - protected $responseFinder; - - /** @var callable(FiberSuspend|null, ?Uuid): void */ - protected $fiberYieldHandler; - - public function onMessage(callable $listener): void - { - $this->messageListener = $listener; - } - - public function onSessionEnd(callable $listener): void - { - $this->sessionEndListener = $listener; - } - - public function setOutgoingMessagesProvider(callable $provider): void - { - $this->outgoingMessagesProvider = $provider; - } - - public function setPendingRequestsProvider(callable $provider): void - { - $this->pendingRequestsProvider = $provider; - } - - /** - * @param callable(int, Uuid):(Response>|Error|null) $finder - */ - public function setResponseFinder(callable $finder): void - { - $this->responseFinder = $finder; - } - - /** - * @param callable(FiberSuspend|null, ?Uuid): void $handler - */ - public function setFiberYieldHandler(callable $handler): void - { - $this->fiberYieldHandler = $handler; - } -} diff --git a/src/Server/Transport/Stdio/RunnerControl.php b/src/Server/Transport/Stdio/RunnerControl.php deleted file mode 100644 index 2b3c941b..00000000 --- a/src/Server/Transport/Stdio/RunnerControl.php +++ /dev/null @@ -1,28 +0,0 @@ - - */ -class RunnerControl implements RunnerControlInterface -{ - public static RunnerState $state = RunnerState::RUNNING; - - public function getState(): RunnerState - { - return self::$state; - } -} diff --git a/src/Server/Transport/Stdio/RunnerControlInterface.php b/src/Server/Transport/Stdio/RunnerControlInterface.php deleted file mode 100644 index d8de01f7..00000000 --- a/src/Server/Transport/Stdio/RunnerControlInterface.php +++ /dev/null @@ -1,22 +0,0 @@ - - */ -interface RunnerControlInterface -{ - public function getState(): RunnerState; -} diff --git a/src/Server/Transport/Stdio/RunnerState.php b/src/Server/Transport/Stdio/RunnerState.php deleted file mode 100644 index c35e5e1a..00000000 --- a/src/Server/Transport/Stdio/RunnerState.php +++ /dev/null @@ -1,24 +0,0 @@ - - */ -enum RunnerState -{ - case RUNNING; - case STOP_AND_END_SESSION; - case STOP; -} diff --git a/src/Server/Transport/StdioTransport.php b/src/Server/Transport/StdioTransport.php deleted file mode 100644 index 565f7da7..00000000 --- a/src/Server/Transport/StdioTransport.php +++ /dev/null @@ -1,214 +0,0 @@ - - * - * @author Kyrian Obikwelu - */ -class StdioTransport extends BaseTransport -{ - /** - * Default cap on the bytes read for a single input line. - */ - public const DEFAULT_MAX_LINE_BYTES = 4 * 1024 * 1024; - - /** Whether the current over-length line is still being drained and discarded. */ - private bool $discardingLine = false; - - /** - * @param resource $input - * @param resource $output - * @param int $maxLineBytes Maximum bytes read for a single input line. fgets() with no length reads until a - * newline or EOF, so a peer that never sends a newline would buffer the whole stream - * into one allocation and exhaust memory; a line exceeding this cap is discarded - * instead. - */ - public function __construct( - private $input = \STDIN, - private $output = \STDOUT, - ?LoggerInterface $logger = null, - private readonly RunnerControlInterface $runnerControl = new RunnerControl(), - private readonly int $maxLineBytes = self::DEFAULT_MAX_LINE_BYTES, - ) { - parent::__construct($logger); - - if ($maxLineBytes < 1) { - throw new InvalidArgumentException(\sprintf('The maximum line size must be a positive number of bytes, got %d.', $maxLineBytes)); - } - } - - public function send(string $data, array $context): void - { - if (isset($context['session_id'])) { - $this->sessionId = $context['session_id']; - } - - $this->writeLine($data); - } - - public function listen(): int - { - $this->logger->info('StdioTransport is listening for messages on STDIN...'); - stream_set_blocking($this->input, false); - - while (!feof($this->input) && RunnerState::RUNNING === $this->runnerControl->getState()) { - $this->processInput(); - $this->processFiber(); - $this->flushOutgoingMessages(); - } - - $this->logger->info('StdioTransport finished listening.'); - if (\in_array($this->runnerControl->getState(), [RunnerState::RUNNING, RunnerState::STOP_AND_END_SESSION], true)) { - $this->logger->info('StdioTransport end session.'); - $this->handleSessionEnd($this->sessionId); - } - - return 0; - } - - protected function processInput(): void - { - $line = fgets($this->input, $this->maxLineBytes); - if (false === $line) { - usleep(50000); // 50ms - - return; - } - - $lineComplete = str_ends_with($line, "\n"); - - // A previous over-length line is still being drained: keep discarding - // one bounded chunk per tick until its terminating newline is reached, - // so the run loop stays responsive instead of blocking on a drain loop. - if ($this->discardingLine) { - $this->discardingLine = !$lineComplete; - - return; - } - - // fgets() reads at most maxLineBytes - 1 bytes; a full read with no - // trailing newline means the line exceeds the cap. Discard it rather - // than buffering it, and keep discarding the remainder on later ticks. - if (!$lineComplete && \strlen($line) >= $this->maxLineBytes - 1) { - $this->discardingLine = true; - $this->logger->warning('StdioTransport discarded an input line exceeding the maximum length.', [ - 'max_line_bytes' => $this->maxLineBytes, - ]); - - return; - } - - $trimmedLine = trim($line); - if (!empty($trimmedLine)) { - $this->handleMessage($trimmedLine, $this->sessionId); - } - } - - private function processFiber(): void - { - if (null === $this->sessionFiber) { - return; - } - - if ($this->sessionFiber->isTerminated()) { - $this->handleFiberTermination(); - - return; - } - - if (!$this->sessionFiber->isSuspended()) { - return; - } - - $pendingRequests = $this->getPendingRequests($this->sessionId); - - if (empty($pendingRequests)) { - $yielded = $this->sessionFiber->resume(); - $this->handleFiberYield($yielded, $this->sessionId); - - return; - } - - foreach ($pendingRequests as $pending) { - $requestId = $pending['request_id']; - $timestamp = $pending['timestamp']; - $timeout = $pending['timeout'] ?? 120; - - $response = $this->checkForResponse($requestId, $this->sessionId); - - if (null !== $response) { - $yielded = $this->sessionFiber->resume($response); - $this->handleFiberYield($yielded, $this->sessionId); - - return; - } - - if (time() - $timestamp >= $timeout) { - $error = Error::forInternalError('Request timed out', $requestId); - $yielded = $this->sessionFiber->resume($error); - $this->handleFiberYield($yielded, $this->sessionId); - - return; - } - } - } - - private function handleFiberTermination(): void - { - $finalResult = $this->sessionFiber->getReturn(); - - if (null !== $finalResult) { - try { - $encoded = json_encode($finalResult, \JSON_THROW_ON_ERROR); - $this->writeLine($encoded); - } catch (\JsonException $e) { - $this->logger->error('STDIO: Failed to encode final Fiber result.', ['exception' => $e]); - } - } - - $this->sessionFiber = null; - } - - private function flushOutgoingMessages(): void - { - $messages = $this->getOutgoingMessages($this->sessionId); - - foreach ($messages as $message) { - $this->writeLine($message['message']); - } - } - - private function writeLine(string $payload): void - { - fwrite($this->output, $payload.\PHP_EOL); - } - - public function close(): void - { - $this->handleSessionEnd($this->sessionId); - if (\is_resource($this->input)) { - fclose($this->input); - } - if (\is_resource($this->output)) { - fclose($this->output); - } - } -} diff --git a/src/Server/Transport/StreamableHttpTransport.php b/src/Server/Transport/StreamableHttpTransport.php deleted file mode 100644 index cce2bd58..00000000 --- a/src/Server/Transport/StreamableHttpTransport.php +++ /dev/null @@ -1,367 +0,0 @@ - - * - * @author Kyrian Obikwelu - */ -class StreamableHttpTransport extends BaseTransport -{ - public const SESSION_HEADER = 'Mcp-Session-Id'; - public const PROTOCOL_VERSION_HEADER = 'Mcp-Protocol-Version'; - - /** - * Upper bound on the request body read for a POST, guarding against memory - * exhaustion from an oversized (or unbounded chunked) payload. - */ - public const DEFAULT_MAX_BODY_BYTES = 4 * 1024 * 1024; - - private ResponseFactoryInterface $responseFactory; - private StreamFactoryInterface $streamFactory; - - private ?string $immediateResponse = null; - private ?int $immediateStatusCode = null; - - /** @var list */ - private array $middleware; - - /** - * @param iterable|null $middleware `null` installs {@see self::defaultMiddleware()}; `[]` disables all middleware - */ - public function __construct( - private ServerRequestInterface $request, - ?ResponseFactoryInterface $responseFactory = null, - ?StreamFactoryInterface $streamFactory = null, - ?LoggerInterface $logger = null, - ?iterable $middleware = null, - private readonly int $maxBodyBytes = self::DEFAULT_MAX_BODY_BYTES, - ) { - parent::__construct($logger); - - if ($this->maxBodyBytes < 1) { - throw new InvalidArgumentException('maxBodyBytes must be at least 1.'); - } - - $this->responseFactory = $responseFactory ?? Psr17FactoryDiscovery::findResponseFactory(); - $this->streamFactory = $streamFactory ?? Psr17FactoryDiscovery::findStreamFactory(); - - if (null === $middleware) { - $this->middleware = self::defaultMiddleware(); - } else { - $this->middleware = self::normalizeMiddleware($middleware); - if ([] === $this->middleware) { - $this->logger->warning('Streamable HTTP transport started with an empty middleware list. Default security protections (CORS, DNS rebinding, protocol version validation) are disabled. Pass null (or omit the argument) to use the secure defaults, or include them via [...StreamableHttpTransport::defaultMiddleware(), $yourMiddleware].'); - } - } - } - - /** - * Secure default middleware stack applied when no `$middleware` is provided to the constructor. - * - * @return list - */ - public static function defaultMiddleware(): array - { - return [ - new CorsMiddleware(), - new DnsRebindingProtectionMiddleware(), - new ProtocolVersionMiddleware(), - ]; - } - - public function send(string $data, array $context): void - { - $this->immediateResponse = $data; - $this->immediateStatusCode = $context['status_code'] ?? 200; - } - - public function listen(): ResponseInterface - { - $handler = new MiddlewareRequestHandler( - $this->middleware, - \Closure::fromCallable([$this, 'handleRequest']), - ); - - return $handler->handle($this->request); - } - - protected function handleOptionsRequest(): ResponseInterface - { - return $this->responseFactory->createResponse(204); - } - - protected function handlePostRequest(): ResponseInterface - { - $body = $this->readBody($this->request->getBody()); - if (null === $body) { - $this->logger->warning('Rejected POST body exceeding the maximum allowed size.', ['limit' => $this->maxBodyBytes]); - - return $this->createErrorResponse(Error::forInvalidRequest(\sprintf('Request body exceeds the maximum allowed size of %d bytes.', $this->maxBodyBytes)), 413); - } - - $this->handleMessage($body, $this->sessionId); - - if (null !== $this->immediateResponse) { - $response = $this->responseFactory->createResponse($this->immediateStatusCode ?? 200) - ->withHeader('Content-Type', 'application/json') - ->withBody($this->streamFactory->createStream($this->immediateResponse)); - - return $response; - } - - if (null !== $this->sessionFiber) { - $this->logger->info('Fiber suspended, handling via SSE.'); - - return $this->createStreamedResponse(); - } - - return $this->createJsonResponse(); - } - - protected function handleDeleteRequest(): ResponseInterface - { - if (!$this->sessionId) { - return $this->createErrorResponse(Error::forInvalidRequest(self::SESSION_HEADER.' header is required.'), 400); - } - - $this->handleSessionEnd($this->sessionId); - - return $this->responseFactory->createResponse(200); - } - - protected function createJsonResponse(): ResponseInterface - { - $outgoingMessages = $this->getOutgoingMessages($this->sessionId); - - if (empty($outgoingMessages)) { - return $this->responseFactory->createResponse(202) - ->withHeader('Content-Type', 'application/json'); - } - - $messages = array_column($outgoingMessages, 'message'); - $responseBody = 1 === \count($messages) ? $messages[0] : '['.implode(',', $messages).']'; - - $response = $this->responseFactory->createResponse(200) - ->withHeader('Content-Type', 'application/json') - ->withBody($this->streamFactory->createStream($responseBody)); - - if ($this->sessionId) { - $response = $response->withHeader(self::SESSION_HEADER, $this->sessionId->toRfc4122()); - } - - return $response; - } - - protected function createStreamedResponse(): ResponseInterface - { - $callback = function (): void { - try { - $this->logger->info('SSE: Starting request processing loop'); - - while ($this->sessionFiber->isSuspended()) { - $this->flushOutgoingMessages($this->sessionId); - - $pendingRequests = $this->getPendingRequests($this->sessionId); - - if (empty($pendingRequests)) { - $yielded = $this->sessionFiber->resume(); - $this->handleFiberYield($yielded, $this->sessionId); - continue; - } - - $resumed = false; - foreach ($pendingRequests as $pending) { - $requestId = $pending['request_id']; - $timestamp = $pending['timestamp']; - $timeout = $pending['timeout'] ?? 120; - - $response = $this->checkForResponse($requestId, $this->sessionId); - - if (null !== $response) { - $yielded = $this->sessionFiber->resume($response); - $this->handleFiberYield($yielded, $this->sessionId); - $resumed = true; - break; - } - - if (time() - $timestamp >= $timeout) { - $error = Error::forInternalError('Request timed out', $requestId); - $yielded = $this->sessionFiber->resume($error); - $this->handleFiberYield($yielded, $this->sessionId); - $resumed = true; - break; - } - } - - if (!$resumed) { - usleep(100000); - } // Prevent tight loop - } - - $this->handleFiberTermination(); - } finally { - $this->sessionFiber = null; - } - }; - - $stream = new CallbackStream($callback, $this->logger); - $response = $this->responseFactory->createResponse(200) - ->withHeader('Content-Type', 'text/event-stream') - ->withHeader('Cache-Control', 'no-cache') - ->withHeader('Connection', 'keep-alive') - ->withHeader('X-Accel-Buffering', 'no') - ->withBody($stream); - - if ($this->sessionId) { - $response = $response->withHeader(self::SESSION_HEADER, $this->sessionId->toRfc4122()); - } - - return $response; - } - - protected function handleFiberTermination(): void - { - $finalResult = $this->sessionFiber->getReturn(); - - if (null !== $finalResult) { - try { - $encoded = json_encode($finalResult, \JSON_THROW_ON_ERROR); - echo "event: message\n"; - echo "data: {$encoded}\n\n"; - @ob_flush(); - flush(); - } catch (\JsonException $e) { - $this->logger->error('SSE: Failed to encode final Fiber result.', ['exception' => $e]); - } - } - - $this->sessionFiber = null; - } - - protected function flushOutgoingMessages(?Uuid $sessionId): void - { - $messages = $this->getOutgoingMessages($sessionId); - - foreach ($messages as $message) { - echo "event: message\n"; - echo "data: {$message['message']}\n\n"; - @ob_flush(); - flush(); - } - } - - protected function createErrorResponse(Error $jsonRpcError, int $statusCode): ResponseInterface - { - $payload = json_encode($jsonRpcError, \JSON_THROW_ON_ERROR); - $response = $this->responseFactory->createResponse($statusCode) - ->withHeader('Content-Type', 'application/json') - ->withBody($this->streamFactory->createStream($payload)); - - if (405 === $statusCode) { - $response = $response->withHeader('Allow', 'POST, DELETE, OPTIONS'); - } - - return $response; - } - - /** - * Reads the request body, bounded by {@see self::$maxBodyBytes}. - * - * Returns the body contents, or `null` when the payload exceeds the cap. When - * the stream advertises a size we reject up-front; otherwise (e.g. chunked - * transfer with unknown size) we read incrementally and stop at the cap so an - * unbounded stream cannot exhaust memory. - */ - private function readBody(StreamInterface $body): ?string - { - $size = $body->getSize(); - if (null !== $size && $size > $this->maxBodyBytes) { - return null; - } - - $contents = ''; - while (!$body->eof()) { - $chunk = $body->read(8192); - if ('' === $chunk) { - break; - } - - $contents .= $chunk; - if (\strlen($contents) > $this->maxBodyBytes) { - return null; - } - } - - return $contents; - } - - /** - * @param iterable $middleware - * - * @return list - */ - private static function normalizeMiddleware(iterable $middleware): array - { - $normalized = []; - foreach ($middleware as $m) { - if (!$m instanceof MiddlewareInterface) { - throw new InvalidArgumentException('Streamable HTTP middleware must implement Psr\\Http\\Server\\MiddlewareInterface.'); - } - $normalized[] = $m; - } - - return $normalized; - } - - private function handleRequest(ServerRequestInterface $request): ResponseInterface - { - $this->request = $request; - $sessionIdHeaders = $request->getHeader(self::SESSION_HEADER); - if (\count($sessionIdHeaders) > 1) { - return $this->createErrorResponse(Error::forInvalidRequest(self::SESSION_HEADER.' header must not be repeated.'), 400); - } - - $sessionIdString = $sessionIdHeaders[0] ?? ''; - - try { - $this->sessionId = $sessionIdString ? Uuid::fromString($sessionIdString) : null; - // Symfony UID 5.4/6.4 throw the global parent; newer versions throw a namespaced subclass. - } catch (\InvalidArgumentException) { - return $this->createErrorResponse(Error::forInvalidRequest(self::SESSION_HEADER.' header must be a valid UUID.'), 400); - } - - return match ($request->getMethod()) { - 'OPTIONS' => $this->handleOptionsRequest(), - 'POST' => $this->handlePostRequest(), - 'DELETE' => $this->handleDeleteRequest(), - default => $this->createErrorResponse(Error::forInvalidRequest('Method Not Allowed'), 405), - }; - } -} diff --git a/src/Server/Transport/TransportInterface.php b/src/Server/Transport/TransportInterface.php deleted file mode 100644 index 58d09789..00000000 --- a/src/Server/Transport/TransportInterface.php +++ /dev/null @@ -1,132 +0,0 @@ -|Error) - * @phpstan-type FiberResume (FiberReturn|null) - * @phpstan-type FiberSuspend ( - * array{type: 'notification', notification: \Mcp\Schema\JsonRpc\Notification}| - * array{type: 'request', request: \Mcp\Schema\JsonRpc\Request, timeout?: int} - * ) - * @phpstan-type McpFiber \Fiber - * - * @author Christopher Hertel - * @author Kyrian Obikwelu - */ -interface TransportInterface -{ - /** - * Initializes the transport. - */ - public function initialize(): void; - - /** - * Starts the transport's execution process. - * - * - For a blocking transport like STDIO, this method will run a continuous loop. - * - For a single-request transport like HTTP, this will process the request - * and return a result (e.g., a PSR-7 Response) to be sent to the client. - * - * @return TResult the result of the transport's execution, if any - */ - public function listen(): mixed; - - /** - * Send a message to the client immediately (bypassing session queue). - * - * Used for session resolution errors when no session is available. - * The transport decides HOW to send based on context. - * - * @param array $context Context about this message: - * - 'session_id': Uuid|null - * - 'type': 'response'|'request'|'notification' - * - 'status_code': int (HTTP status code for errors) - */ - public function send(string $data, array $context): void; - - /** - * Closes the transport and cleans up any resources. - */ - public function close(): void; - - /** - * Register callback for ALL incoming messages. - * - * The transport calls this whenever ANY message arrives, regardless of source. - * - * @param callable(TransportInterface $transport, string $message, ?Uuid $sessionId): void $listener - */ - public function onMessage(callable $listener): void; - - /** - * Register a listener for when a session is terminated. - * - * The transport calls this when a client disconnects or explicitly ends their session. - * - * @param callable(Uuid $sessionId): void $listener The callback function to execute when destroying a session - */ - public function onSessionEnd(callable $listener): void; - - /** - * Set a provider function to retrieve all queued outgoing messages. - * - * The transport calls this to retrieve all queued messages for a session. - * - * @param callable(Uuid $sessionId): array}> $provider - */ - public function setOutgoingMessagesProvider(callable $provider): void; - - /** - * Set a provider function to retrieve all pending server-initiated requests. - * - * The transport calls this to decide if it should wait for a client response before resuming a Fiber. - * - * @param callable(Uuid $sessionId): array> $provider - */ - public function setPendingRequestsProvider(callable $provider): void; - - /** - * Set a finder function to check for a specific client response. - * - * @param callable(int, Uuid):FiberResume $finder - */ - public function setResponseFinder(callable $finder): void; - - /** - * Set a handler for processing values yielded from a suspended Fiber. - * - * The transport calls this to let the Protocol handle new requests/notifications - * that are yielded from a Fiber's execution. - * - * @param callable(FiberSuspend|null, ?Uuid $sessionId): void $handler - */ - public function setFiberYieldHandler(callable $handler): void; - - /** - * @param McpFiber $fiber - */ - public function attachFiberToSession(\Fiber $fiber, Uuid $sessionId): void; - - /** - * Set the session ID for the current transport context. - * - * @param Uuid|null $sessionId The session ID, or null to clear - */ - public function setSessionId(?Uuid $sessionId): void; -} diff --git a/tests/Conformance/Elements.php b/tests/Conformance/Elements.php deleted file mode 100644 index 6d65fd22..00000000 --- a/tests/Conformance/Elements.php +++ /dev/null @@ -1,195 +0,0 @@ -getClientLogger(); - - $logger->info('Tool execution started'); - $logger->info('Tool processing data'); - $logger->info('Tool execution completed'); - - return 'Tool with logging executed successfully'; - } - - public function toolWithProgress(RequestContext $context): ?string - { - $client = $context->getClientGateway(); - - $client->progress(0, 100, 'Completed step 0 of 100'); - $client->progress(50, 100, 'Completed step 50 of 100'); - $client->progress(100, 100, 'Completed step 100 of 100'); - - $meta = $context->getSession()->get(Protocol::SESSION_ACTIVE_REQUEST_META, []); - - return $meta['progressToken'] ?? null; - } - - /** - * @param string $prompt The prompt to send to the LLM - */ - public function toolWithSampling(RequestContext $context, string $prompt): string - { - $result = $context->getClientGateway()->sample($prompt, 100); - - return \sprintf( - 'LLM response: %s', - $result->content instanceof TextContent ? trim((string) $result->content->text) : '' - ); - } - - /** - * @param string $message The message to display to the user - */ - public function toolWithElicitation(RequestContext $context, string $message): string - { - $schema = new ElicitationSchema( - properties: [ - 'username' => new StringSchemaDefinition('Username'), - 'email' => new StringSchemaDefinition('Email'), - ], - ); - - $context->getClientGateway()->elicit($message, $schema); - - return 'ok'; - } - - public function toolWithElicitationDefaults(RequestContext $context): string - { - $schema = new ElicitationSchema( - properties: [ - 'name' => new StringSchemaDefinition('Name', default: 'John Doe'), - 'age' => new NumberSchemaDefinition('Age', integerOnly: true, default: 30), - 'score' => new NumberSchemaDefinition('Score', default: 95.5), - 'status' => new EnumSchemaDefinition('Status', enum: ['active', 'inactive', 'pending'], default: 'active'), - 'verified' => new BooleanSchemaDefinition('Verified', default: true), - ], - ); - - $context->getClientGateway()->elicit('Provide profile information', $schema); - - return 'ok'; - } - - public function toolWithElicitationEnums(RequestContext $context): string - { - $schema = new ElicitationSchema( - properties: [ - 'untitledSingle' => new EnumSchemaDefinition('Untitled Single', enum: ['option1', 'option2', 'option3']), - 'titledSingle' => new TitledEnumSchemaDefinition('Titled Single', oneOf: [ - ['const' => 'value1', 'title' => 'Label 1'], - ['const' => 'value2', 'title' => 'Label 2'], - ['const' => 'value3', 'title' => 'Label 3'], - ]), - 'legacyEnum' => new EnumSchemaDefinition('Legacy Enum', enum: ['opt1', 'opt2', 'opt3'], enumNames: ['Option 1', 'Option 2', 'Option 3']), - 'untitledMulti' => new MultiSelectEnumSchemaDefinition('Untitled Multi', enum: ['option1', 'option2', 'option3']), - 'titledMulti' => new TitledMultiSelectEnumSchemaDefinition('Titled Multi', anyOf: [ - ['const' => 'value1', 'title' => 'Label 1'], - ['const' => 'value2', 'title' => 'Label 2'], - ['const' => 'value3', 'title' => 'Label 3'], - ]), - ], - ); - - $context->getClientGateway()->elicit('Select options', $schema); - - return 'ok'; - } - - public function resourceTemplate(string $id): TextResourceContents - { - return new TextResourceContents( - uri: 'test://template/{id}/data', - mimeType: 'application/json', - text: json_encode([ - 'id' => $id, - 'templateTest' => true, - 'data' => \sprintf('Data for ID: %s', $id), - ]), - ); - } - - /** - * @param string $arg1 First test argument - * @param string $arg2 Second test argument - * - * @return PromptMessage[] - */ - public function promptWithArguments(string $arg1, string $arg2): array - { - return [ - new PromptMessage(Role::User, new TextContent(\sprintf('Prompt with arguments: arg1="%s", arg2="%s"', $arg1, $arg2))), - ]; - } - - /** - * @param string $resourceUri URI of the resource to embed - * - * @return PromptMessage[] - */ - public function promptWithEmbeddedResource(string $resourceUri): array - { - return [ - new PromptMessage(Role::User, EmbeddedResource::fromText($resourceUri, 'Embedded resource content for testing.')), - new PromptMessage(Role::User, new TextContent('Please process the embedded resource above.')), - ]; - } - - /** - * @return PromptMessage[] - */ - public function promptWithImage(): array - { - return [ - new PromptMessage(Role::User, new ImageContent(self::TEST_IMAGE_BASE64, 'image/png')), - new PromptMessage(Role::User, new TextContent('Please analyze the image above.')), - ]; - } -} diff --git a/tests/Conformance/FileLogger.php b/tests/Conformance/FileLogger.php deleted file mode 100644 index 55bead3d..00000000 --- a/tests/Conformance/FileLogger.php +++ /dev/null @@ -1,33 +0,0 @@ -debug && 'debug' === $level) { - return; - } - - $logMessage = \sprintf("[%s] %s\n", strtoupper($level), $message); - file_put_contents($this->filePath, $logMessage, \FILE_APPEND); - } -} diff --git a/tests/Conformance/Fixtures/docker-compose.yml b/tests/Conformance/Fixtures/docker-compose.yml deleted file mode 100644 index 62e2e8bd..00000000 --- a/tests/Conformance/Fixtures/docker-compose.yml +++ /dev/null @@ -1,25 +0,0 @@ -services: - nginx: - image: nginx:1.26-alpine - ports: - - "8000:80" - volumes: - - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro - - ../../..:/app:ro - depends_on: - - php-fpm - networks: - - mcp-net - - php-fpm: - image: php:8.4-fpm-alpine - volumes: - - ../../..:/app:ro - - ../sessions:/app/tests/Conformance/sessions - - ../logs:/app/tests/Conformance/logs - working_dir: /app - networks: - - mcp-net - -networks: - mcp-net: diff --git a/tests/Conformance/Fixtures/nginx.conf b/tests/Conformance/Fixtures/nginx.conf deleted file mode 100644 index 9159c461..00000000 --- a/tests/Conformance/Fixtures/nginx.conf +++ /dev/null @@ -1,15 +0,0 @@ -server { - listen 80; - server_name localhost; - root /app; - - location / { - try_files $uri /tests/Conformance/server.php$is_args$args; - } - - location ~ \.php$ { - fastcgi_pass php-fpm:9000; - fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; - include fastcgi_params; - } -} diff --git a/tests/Conformance/client.php b/tests/Conformance/client.php deleted file mode 100644 index 74ac8e90..00000000 --- a/tests/Conformance/client.php +++ /dev/null @@ -1,108 +0,0 @@ - php client.php \n"); - exit(1); -} - -@mkdir(__DIR__.'/logs', 0777, true); -$logger = new FileLogger(__DIR__.'/logs/client-conformance.log', true); -$logger->info(sprintf('Starting client conformance test: scenario=%s, url=%s', $scenario, $url)); - -$builder = Client::builder() - ->setClientInfo('mcp-conformance-test-client', '1.0.0') - ->setInitTimeout(30) - ->setRequestTimeout(60) - ->setLogger($logger); - -if ('elicitation-sep1034-client-defaults' === $scenario) { - $builder->setCapabilities(new ClientCapabilities(elicitation: true)); - $builder->addRequestHandler(new class($logger) implements RequestHandlerInterface { - public function __construct(private readonly Psr\Log\LoggerInterface $logger) - { - } - - public function supports(Request $request): bool - { - return $request instanceof ElicitRequest; - } - - public function handle(Request $request): Response - { - $this->logger->info('Received elicitation request, accepting with empty content'); - - return new Response($request->getId(), new ElicitResult(ElicitAction::Accept, [])); - } - }); -} - -$client = $builder->build(); -$transport = new HttpTransport($url, logger: $logger); - -try { - $client->connect($transport); - $logger->info('Connected to server'); - - $toolsResult = $client->listTools(); - $logger->info(sprintf('Listed %d tools', count($toolsResult->tools))); - - switch ($scenario) { - case 'initialize': - break; - - case 'tools_call': - $toolName = $toolsResult->tools[0]->name ?? 'test-tool'; - $client->callTool($toolName, []); - $logger->info(sprintf('Called tool: %s', $toolName)); - break; - - case 'elicitation-sep1034-client-defaults': - $toolName = $toolsResult->tools[0]->name ?? 'test_client_elicitation_defaults'; - $client->callTool($toolName, []); - $logger->info(sprintf('Called tool: %s', $toolName)); - break; - - default: - $logger->warning(sprintf('Unknown scenario: %s', $scenario)); - break; - } - - $client->disconnect(); - $logger->info('Disconnected'); - exit(0); -} catch (Throwable $e) { - $logger->error(sprintf('Error: %s', $e->getMessage()), ['exception' => $e]); - fwrite(\STDERR, sprintf("Error: %s\n%s\n", $e->getMessage(), $e->getTraceAsString())); - - try { - $client->disconnect(); - } catch (Throwable $ignored) { - } - - exit(1); -} diff --git a/tests/Conformance/conformance-baseline.yml b/tests/Conformance/conformance-baseline.yml deleted file mode 100644 index efda80ab..00000000 --- a/tests/Conformance/conformance-baseline.yml +++ /dev/null @@ -1,27 +0,0 @@ -server: [] - -client: - - elicitation-sep1034-client-defaults - - sse-retry - - auth/metadata-default - - auth/metadata-var1 - - auth/metadata-var2 - - auth/metadata-var3 - - auth/basic-cimd - - auth/scope-from-www-authenticate - - auth/scope-from-scopes-supported - - auth/scope-omitted-when-undefined - - auth/scope-step-up - - auth/scope-retry-limit - - auth/token-endpoint-auth-basic - - auth/token-endpoint-auth-post - - auth/token-endpoint-auth-none - - auth/pre-registration - - auth/2025-03-26-oauth-metadata-backcompat - - auth/2025-03-26-oauth-endpoint-fallback - - auth/offline-access-scope - - auth/offline-access-not-supported - - auth/client-credentials-jwt - - auth/client-credentials-basic - - auth/cross-app-access-complete-flow - diff --git a/tests/Conformance/score.php b/tests/Conformance/score.php deleted file mode 100644 index ddad90f1..00000000 --- a/tests/Conformance/score.php +++ /dev/null @@ -1,119 +0,0 @@ - - * - * The conformance CLI (run with `--output-dir results`) writes one - * `checks.json` per scenario into the `results/` directory next to this file. - * A scenario counts as passing when none of its checks has a FAILURE status; - * the badge message is "/ (%)" and is written to - * `-conformance.json`. - */ - -use Symfony\Component\Console\Command\Command; -use Symfony\Component\Console\Input\InputArgument; -use Symfony\Component\Console\Input\InputInterface; -use Symfony\Component\Console\Output\OutputInterface; -use Symfony\Component\Console\SingleCommandApplication; -use Symfony\Component\Console\Style\SymfonyStyle; -use Symfony\Component\Finder\Finder; - -require_once dirname(__DIR__, 2).'/vendor/autoload.php'; - -(new SingleCommandApplication()) - ->setName('conformance-score') - ->setDescription('Generates a shields.io endpoint badge from the conformance results') - ->addArgument('suite', InputArgument::REQUIRED, 'Which conformance suite was run: "server" or "client"') - ->setCode(static function (InputInterface $input, OutputInterface $output): int { - $io = new SymfonyStyle($input, $output); - - $suite = $input->getArgument('suite'); - - if (!in_array($suite, ['server', 'client'], true)) { - $io->error(sprintf('Suite must be "server" or "client", got "%s".', $suite)); - - return Command::INVALID; - } - - $resultsDir = __DIR__.'/results'; - - if (!is_dir($resultsDir)) { - $io->error(sprintf('Results directory "%s" does not exist; run the conformance suite with `--output-dir results` first.', $resultsDir)); - - return Command::FAILURE; - } - - $total = 0; - $passed = 0; - $failures = []; - - foreach (Finder::create()->files()->name('checks.json')->in($resultsDir) as $file) { - $checks = json_decode($file->getContents(), true); - - if (!is_array($checks)) { - $io->warning(sprintf('Skipping unreadable result file "%s".', $file->getRelativePathname())); - - continue; - } - - foreach ($checks as $check) { - switch ($check['status'] ?? null) { - case 'FAILURE': - $failures[] = $file->getRelativePath(); - break; - case 'SUCCESS': - ++$passed; - break; - default: - continue 2; - } - - ++$total; - } - } - - $pct = $total > 0 ? (int) round($passed / $total * 100) : 0; - - $badge = [ - 'schemaVersion' => 1, - 'label' => $suite.' conformance', - 'message' => $total > 0 ? sprintf('%d/%d (%d%%)', $passed, $total, $pct) : 'no data', - 'color' => match (true) { - 0 === $total => 'lightgrey', - $pct >= 95 => 'brightgreen', - $pct >= 80 => 'green', - $pct >= 60 => 'yellow', - default => 'orange', - }, - ]; - - $outputFile = __DIR__.'/'.$suite.'-conformance.json'; - - if (false === file_put_contents($outputFile, json_encode($badge, \JSON_PRETTY_PRINT | \JSON_UNESCAPED_SLASHES)."\n")) { - $io->error(sprintf('Could not write badge file "%s".', $outputFile)); - - return Command::FAILURE; - } - - if ($failures && $io->isVerbose()) { - $io->section('Failing scenarios'); - $io->listing($failures); - } - - $io->success(sprintf('%s: %s', $badge['label'], $badge['message'])); - - return Command::SUCCESS; - }) - ->run(); diff --git a/tests/Conformance/server.php b/tests/Conformance/server.php deleted file mode 100644 index 02c78c2a..00000000 --- a/tests/Conformance/server.php +++ /dev/null @@ -1,69 +0,0 @@ -createServerRequestFromGlobals(); - -$transport = new StreamableHttpTransport($request, logger: $logger); - -$server = Server::builder() - ->setServerInfo('mcp-conformance-test-server', '1.0.0') - ->setSession(new FileSessionStore(__DIR__.'/sessions')) - ->setLogger($logger) - // Tools - ->addTool(static fn () => 'This is a simple text response for testing.', name: 'test_simple_text', description: 'Tests simple text content response') - ->addTool(static fn () => new ImageContent(Elements::TEST_IMAGE_BASE64, 'image/png'), name: 'test_image_content', description: 'Tests image content response') - ->addTool(static fn () => new AudioContent(Elements::TEST_AUDIO_BASE64, 'audio/wav'), name: 'test_audio_content', description: 'Tests audio content response') - ->addTool(static fn () => EmbeddedResource::fromText('test://embedded-resource', 'This is an embedded resource content.'), name: 'test_embedded_resource', description: 'Tests embedded resource content response') - ->addTool([Elements::class, 'toolMultipleTypes'], name: 'test_multiple_content_types', description: 'Tests response with multiple content types') - ->addTool([Elements::class, 'toolWithLogging'], name: 'test_tool_with_logging', description: 'Tests tool that emits log messages') - ->addTool([Elements::class, 'toolWithProgress'], name: 'test_tool_with_progress', description: 'Tests tool that reports progress notifications') - ->addTool([Elements::class, 'toolWithSampling'], name: 'test_sampling', description: 'Tests server-initiated sampling') - ->addTool(static fn () => CallToolResult::error([new TextContent('This tool intentionally returns an error for testing')]), name: 'test_error_handling', description: 'Tests error response handling') - ->addTool([Elements::class, 'toolWithElicitation'], name: 'test_elicitation', description: 'Tests server-initiated elicitation') - ->addTool([Elements::class, 'toolWithElicitationDefaults'], name: 'test_elicitation_sep1034_defaults', description: 'Tests elicitation with default values') - ->addTool([Elements::class, 'toolWithElicitationEnums'], name: 'test_elicitation_sep1330_enums', description: 'Tests elicitation with enum schemas') - // Resources - ->addResource(static fn () => 'This is the content of the static text resource.', 'test://static-text', 'static-text', 'A static text resource for testing') - ->addResource(static fn () => fopen('data://image/png;base64,'.Elements::TEST_IMAGE_BASE64, 'r'), 'test://static-binary', 'static-binary', 'A static binary resource (image) for testing') - ->addResourceTemplate([Elements::class, 'resourceTemplate'], 'test://template/{id}/data', 'template', 'A resource template with parameter substitution', 'application/json') - ->addResource(static fn () => 'Watched resource content', 'test://watched-resource', 'watched-resource', 'A resource that can be watched') - // Prompts - ->addPrompt(static fn () => [['role' => 'user', 'content' => 'This is a simple prompt for testing.']], name: 'test_simple_prompt', description: 'A simple prompt without arguments') - ->addPrompt([Elements::class, 'promptWithArguments'], name: 'test_prompt_with_arguments', description: 'A prompt with required arguments') - ->addPrompt([Elements::class, 'promptWithEmbeddedResource'], name: 'test_prompt_with_embedded_resource', description: 'A prompt that includes an embedded resource') - ->addPrompt([Elements::class, 'promptWithImage'], name: 'test_prompt_with_image', description: 'A prompt that includes image content') - ->build(); - -$response = $server->run($transport); - -(new SapiEmitter())->emit($response); diff --git a/tests/Inspector/Http/HttpClientCommunicationTest.php b/tests/Inspector/Http/HttpClientCommunicationTest.php deleted file mode 100644 index c74d5cb1..00000000 --- a/tests/Inspector/Http/HttpClientCommunicationTest.php +++ /dev/null @@ -1,64 +0,0 @@ -markTestSkipped('Test skipped: SDK cannot handle logging/setLevel requests required by logging capability, and built-in PHP server does not support sampling.'); - } - - public static function provideMethods(): array - { - return [ - ...parent::provideMethods(), - 'Prepare Project Briefing (Simple)' => [ - 'method' => 'tools/call', - 'options' => [ - 'toolName' => 'prepare_project_briefing', - 'toolArgs' => [ - 'projectName' => 'Website Redesign', - 'milestones' => ['Discovery', 'Design', 'Development', 'Testing'], - ], - ], - 'testName' => 'prepare_project_briefing_simple', - ], - 'Prepare Project Briefing (Complex)' => [ - 'method' => 'tools/call', - 'options' => [ - 'toolName' => 'prepare_project_briefing', - 'toolArgs' => [ - 'projectName' => 'Mobile App Launch', - 'milestones' => ['Market Research', 'UI/UX Design', 'MVP Development', 'Beta Testing', 'Marketing Campaign', 'Public Launch'], - ], - ], - 'testName' => 'prepare_project_briefing_complex', - ], - 'Run Service Maintenance' => [ - 'method' => 'tools/call', - 'options' => [ - 'toolName' => 'run_service_maintenance', - 'toolArgs' => [ - 'serviceName' => 'Payment Gateway API', - ], - ], - 'testName' => 'run_service_maintenance', - ], - ]; - } - - protected function getServerScript(): string - { - return \dirname(__DIR__, 3).'/examples/server/client-communication/server.php'; - } -} diff --git a/tests/Inspector/Http/HttpCombinedRegistrationTest.php b/tests/Inspector/Http/HttpCombinedRegistrationTest.php deleted file mode 100644 index ff475928..00000000 --- a/tests/Inspector/Http/HttpCombinedRegistrationTest.php +++ /dev/null @@ -1,58 +0,0 @@ - [ - 'method' => 'tools/call', - 'options' => [ - 'toolName' => 'manualGreeter', - 'toolArgs' => ['user' => 'HTTP Test User'], - ], - 'testName' => 'manual_greeter', - ], - 'Instance Greeter Tool (pre-built object handler)' => [ - 'method' => 'tools/call', - 'options' => [ - 'toolName' => 'instance_greeter', - 'toolArgs' => ['name' => 'HTTP Test User'], - ], - 'testName' => 'instance_greeter', - ], - 'Discovered Status Check Tool' => [ - 'method' => 'tools/call', - 'options' => [ - 'toolName' => 'discovered_status_check', - 'toolArgs' => [], - ], - 'testName' => 'discovered_status_check', - ], - 'Read Priority Config (Manual Override)' => [ - 'method' => 'resources/read', - 'options' => [ - 'uri' => 'config://priority', - ], - 'testName' => 'config_priority', - ], - ]; - } - - protected function getServerScript(): string - { - return \dirname(__DIR__, 3).'/examples/server/combined-registration/server.php'; - } -} diff --git a/tests/Inspector/Http/HttpComplexToolSchemaTest.php b/tests/Inspector/Http/HttpComplexToolSchemaTest.php deleted file mode 100644 index 1ef58807..00000000 --- a/tests/Inspector/Http/HttpComplexToolSchemaTest.php +++ /dev/null @@ -1,87 +0,0 @@ - [ - 'method' => 'tools/call', - 'options' => [ - 'toolName' => 'schedule_event', - 'toolArgs' => [ - 'title' => 'Team Standup', - 'date' => '2024-12-01', - 'type' => 'meeting', - 'time' => '09:00', - 'priority' => 'normal', - 'attendees' => ['alice@example.com', 'bob@example.com'], - 'sendInvites' => true, - ], - ], - 'testName' => 'schedule_event_meeting_with_time', - ], - 'Schedule Event (All Day Reminder)' => [ - 'method' => 'tools/call', - 'options' => [ - 'toolName' => 'schedule_event', - 'toolArgs' => [ - 'title' => 'Project Deadline', - 'date' => '2024-12-15', - 'type' => 'reminder', - 'priority' => 'high', - ], - ], - 'testName' => 'schedule_event_all_day_reminder', - ], - 'Schedule Event (Call with High Priority)' => [ - 'method' => 'tools/call', - 'options' => [ - 'toolName' => 'schedule_event', - 'toolArgs' => [ - 'title' => 'Client Call', - 'date' => '2024-12-02', - 'type' => 'call', - 'time' => '14:30', - 'priority' => 'high', - 'attendees' => ['client@example.com'], - 'sendInvites' => false, - ], - ], - 'testName' => 'schedule_event_high_priority', - ], - 'Schedule Event (Other Event with Low Priority)' => [ - 'method' => 'tools/call', - 'options' => [ - 'toolName' => 'schedule_event', - 'toolArgs' => [ - 'title' => 'Office Party', - 'date' => '2024-12-20', - 'type' => 'other', - 'time' => '18:00', - 'priority' => 'low', - 'attendees' => ['team@company.com'], - ], - ], - 'testName' => 'schedule_event_low_priority', - ], - ]; - } - - protected function getServerScript(): string - { - return \dirname(__DIR__, 3).'/examples/server/complex-tool-schema/server.php'; - } -} diff --git a/tests/Inspector/Http/HttpDiscoveryUserProfileTest.php b/tests/Inspector/Http/HttpDiscoveryUserProfileTest.php deleted file mode 100644 index 353230a7..00000000 --- a/tests/Inspector/Http/HttpDiscoveryUserProfileTest.php +++ /dev/null @@ -1,88 +0,0 @@ - [ - 'method' => 'tools/call', - 'options' => [ - 'toolName' => 'lookup_user', - 'toolArgs' => ['userId' => '"101"'], - ], - 'testName' => 'lookup_user', - ], - 'Lookup User Tool (Not Found)' => [ - 'method' => 'tools/call', - 'options' => [ - 'toolName' => 'lookup_user', - 'toolArgs' => ['userId' => '"999"'], - ], - 'testName' => 'lookup_user_not_found', - ], - 'Send Welcome Tool' => [ - 'method' => 'tools/call', - 'options' => [ - 'toolName' => 'send_welcome', - 'toolArgs' => ['userId' => '"101"', 'customMessage' => 'Welcome to our platform!'], - ], - 'testName' => 'send_welcome', - ], - 'Test Tool Without Params' => [ - 'method' => 'tools/call', - 'options' => [ - 'toolName' => 'test_tool_without_params', - 'toolArgs' => [], - ], - 'testName' => 'test_tool_without_params', - ], - 'Read User Profile 101' => [ - 'method' => 'resources/read', - 'options' => [ - 'uri' => 'user://101/profile', - ], - 'testName' => 'read_user_profile_101', - ], - 'Read User Profile 102' => [ - 'method' => 'resources/read', - 'options' => [ - 'uri' => 'user://102/profile', - ], - 'testName' => 'read_user_profile_102', - ], - 'Read User ID List' => [ - 'method' => 'resources/read', - 'options' => [ - 'uri' => 'user://list/ids', - ], - 'testName' => 'read_user_id_list', - ], - 'Generate Bio Prompt (Formal)' => [ - 'method' => 'prompts/get', - 'options' => [ - 'promptName' => 'generate_bio_prompt', - 'promptArgs' => ['userId' => '101', 'tone' => 'formal'], - ], - 'testName' => 'generate_bio_prompt', - ], - ]; - } - - protected function getServerScript(): string - { - return \dirname(__DIR__, 3).'/examples/server/discovery-userprofile/server.php'; - } -} diff --git a/tests/Inspector/Http/HttpInspectorSnapshotTestCase.php b/tests/Inspector/Http/HttpInspectorSnapshotTestCase.php deleted file mode 100644 index 5db629e4..00000000 --- a/tests/Inspector/Http/HttpInspectorSnapshotTestCase.php +++ /dev/null @@ -1,100 +0,0 @@ -startServer(); - } - - protected function tearDown(): void - { - $this->stopServer(); - } - - abstract protected function getServerScript(): string; - - protected function getServerConnectionArgs(): array - { - return [\sprintf('http://127.0.0.1:%d', $this->serverPort)]; - } - - protected function getTransport(): string - { - return 'http'; - } - - private function startServer(): void - { - $this->serverPort = 8000 + (getmypid() % 1000); - - $this->serverProcess = new Process([ - 'php', - '-S', - \sprintf('127.0.0.1:%d', $this->serverPort), - $this->getServerScript(), - ]); - - $this->serverProcess->start(); - - $timeout = 5; // seconds - $startTime = time(); - - while (time() - $startTime < $timeout) { - if ($this->serverProcess->isRunning() && $this->isServerReady()) { - return; - } - usleep(100000); // 100ms - } - - $this->fail(\sprintf('Server failed to start on port %d within %d seconds', $this->serverPort, $timeout)); - } - - private function stopServer(): void - { - if (isset($this->serverProcess)) { - $this->serverProcess->stop(1, \SIGTERM); - } - } - - private function isServerReady(): bool - { - $context = stream_context_create([ - 'http' => [ - 'timeout' => 1, - 'method' => 'GET', - ], - ]); - - // Try a simple health check - this will likely fail with MCP but should respond - $response = @file_get_contents(\sprintf('http://127.0.0.1:%d', $this->serverPort), false, $context); - - // We don't care about the response content, just that the server is accepting connections - return false !== $response || false === str_contains(error_get_last()['message'] ?? '', 'Connection refused'); - } - - protected function getSnapshotFilePath(string $method, ?string $testName = null): string - { - $className = substr(static::class, strrpos(static::class, '\\') + 1); - $suffix = $testName ? '-'.preg_replace('/[^a-zA-Z0-9_]/', '_', $testName) : ''; - - return __DIR__.'/snapshots/'.$className.'-'.str_replace('/', '_', $method).$suffix.'.json'; - } -} diff --git a/tests/Inspector/Http/HttpSchemaShowcaseTest.php b/tests/Inspector/Http/HttpSchemaShowcaseTest.php deleted file mode 100644 index 51a00b72..00000000 --- a/tests/Inspector/Http/HttpSchemaShowcaseTest.php +++ /dev/null @@ -1,116 +0,0 @@ - [ - 'method' => 'tools/call', - 'options' => [ - 'toolName' => 'format_text', - 'toolArgs' => ['text' => 'Hello World Test', 'format' => 'uppercase'], - ], - 'testName' => 'format_text', - ], - 'Calculate Range Tool' => [ - 'method' => 'tools/call', - 'options' => [ - 'toolName' => 'calculate_range', - 'toolArgs' => ['first' => 10, 'second' => 5, 'operation' => 'multiply', 'precision' => 2], - ], - 'testName' => 'calculate_range', - ], - 'Validate Profile Tool' => [ - 'method' => 'tools/call', - 'options' => [ - 'toolName' => 'validate_profile', - 'toolArgs' => [ - 'profile' => ['name' => 'John Doe', 'email' => 'john@example.com', 'age' => 30, 'role' => 'user'], - ], - ], - 'testName' => 'validate_profile', - ], - 'Manage List Tool' => [ - 'method' => 'tools/call', - 'options' => [ - 'toolName' => 'manage_list', - 'toolArgs' => [ - 'items' => ['apple', 'banana', 'cherry', 'date'], - 'action' => 'sort', - ], - ], - 'testName' => 'manage_list', - ], - 'Generate Config Tool' => [ - 'method' => 'tools/call', - 'options' => [ - 'toolName' => 'generate_config', - 'toolArgs' => [ - 'appName' => 'TestApp', - 'baseUrl' => 'https://example.com', - 'environment' => 'development', - 'debug' => true, - 'port' => 8080, - ], - ], - 'testName' => 'generate_config', - ], - 'Schedule Event Tool' => [ - 'method' => 'tools/call', - 'options' => [ - 'toolName' => 'schedule_event', - 'toolArgs' => [ - 'title' => 'Team Meeting', - 'startTime' => '2024-12-01T14:30:00Z', - 'durationHours' => 1.5, - 'priority' => 'high', - 'attendees' => ['alice@example.com', 'bob@example.com'], - ], - ], - 'testName' => 'schedule_event', - ], - ]; - } - - protected function getServerScript(): string - { - return \dirname(__DIR__, 3).'/examples/server/schema-showcase/server.php'; - } - - protected function normalizeTestOutput(string $output, ?string $testName = null): string - { - return match ($testName) { - 'validate_profile' => preg_replace( - '/\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}/', - '2025-01-01 00:00:00', - $output - ), - 'generate_config' => preg_replace( - '/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}[+-]\d{2}:\d{2}/', - '2025-01-01T00:00:00+00:00', - $output - ), - 'schedule_event' => preg_replace([ - '/event_[a-f0-9]{13,}/', - '/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}[+-]\d{2}:\d{2}/', - ], [ - 'event_test123456789', - '2025-01-01T00:00:00+00:00', - ], $output), - default => $output, - }; - } -} diff --git a/tests/Inspector/Http/snapshots/HttpCombinedRegistrationTest-prompts_list.json b/tests/Inspector/Http/snapshots/HttpCombinedRegistrationTest-prompts_list.json deleted file mode 100644 index 7292222c..00000000 --- a/tests/Inspector/Http/snapshots/HttpCombinedRegistrationTest-prompts_list.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "prompts": [] -} diff --git a/tests/Inspector/Http/snapshots/HttpCombinedRegistrationTest-resources_list.json b/tests/Inspector/Http/snapshots/HttpCombinedRegistrationTest-resources_list.json deleted file mode 100644 index f3646e57..00000000 --- a/tests/Inspector/Http/snapshots/HttpCombinedRegistrationTest-resources_list.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "resources": [ - { - "name": "priority_config_manual", - "uri": "config://priority", - "description": "Manually registered resource that overrides a discovered one." - } - ] -} diff --git a/tests/Inspector/Http/snapshots/HttpCombinedRegistrationTest-resources_read-config_priority.json b/tests/Inspector/Http/snapshots/HttpCombinedRegistrationTest-resources_read-config_priority.json deleted file mode 100644 index 85eef65a..00000000 --- a/tests/Inspector/Http/snapshots/HttpCombinedRegistrationTest-resources_read-config_priority.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "contents": [ - { - "uri": "config://priority", - "mimeType": "text/plain", - "text": "Manual Priority Config: HIGH (overrides discovered)" - } - ] -} diff --git a/tests/Inspector/Http/snapshots/HttpCombinedRegistrationTest-resources_templates_list.json b/tests/Inspector/Http/snapshots/HttpCombinedRegistrationTest-resources_templates_list.json deleted file mode 100644 index e867d9d2..00000000 --- a/tests/Inspector/Http/snapshots/HttpCombinedRegistrationTest-resources_templates_list.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "resourceTemplates": [] -} diff --git a/tests/Inspector/Http/snapshots/HttpCombinedRegistrationTest-tools_call-discovered_status_check.json b/tests/Inspector/Http/snapshots/HttpCombinedRegistrationTest-tools_call-discovered_status_check.json deleted file mode 100644 index d849f400..00000000 --- a/tests/Inspector/Http/snapshots/HttpCombinedRegistrationTest-tools_call-discovered_status_check.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "content": [ - { - "type": "text", - "text": "System status: OK (discovered)" - } - ], - "isError": false -} diff --git a/tests/Inspector/Http/snapshots/HttpCombinedRegistrationTest-tools_call-instance_greeter.json b/tests/Inspector/Http/snapshots/HttpCombinedRegistrationTest-tools_call-instance_greeter.json deleted file mode 100644 index 4b62f1b3..00000000 --- a/tests/Inspector/Http/snapshots/HttpCombinedRegistrationTest-tools_call-instance_greeter.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "content": [ - { - "type": "text", - "text": "Willkommen, HTTP Test User!" - } - ], - "isError": false -} diff --git a/tests/Inspector/Http/snapshots/HttpCombinedRegistrationTest-tools_call-manual_greeter.json b/tests/Inspector/Http/snapshots/HttpCombinedRegistrationTest-tools_call-manual_greeter.json deleted file mode 100644 index 4d8cf0da..00000000 --- a/tests/Inspector/Http/snapshots/HttpCombinedRegistrationTest-tools_call-manual_greeter.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "content": [ - { - "type": "text", - "text": "Hello HTTP Test User, from manual registration!" - } - ], - "isError": false -} diff --git a/tests/Inspector/Http/snapshots/HttpCombinedRegistrationTest-tools_list.json b/tests/Inspector/Http/snapshots/HttpCombinedRegistrationTest-tools_list.json deleted file mode 100644 index 0812699a..00000000 --- a/tests/Inspector/Http/snapshots/HttpCombinedRegistrationTest-tools_list.json +++ /dev/null @@ -1,44 +0,0 @@ -{ - "tools": [ - { - "name": "manualGreeter", - "description": "A manually registered tool.", - "inputSchema": { - "type": "object", - "properties": { - "user": { - "type": "string", - "description": "the user to greet" - } - }, - "required": [ - "user" - ] - } - }, - { - "name": "instance_greeter", - "description": "A tool registered as a pre-built object instance.", - "inputSchema": { - "type": "object", - "properties": { - "name": { - "type": "string", - "description": "the name to greet" - } - }, - "required": [ - "name" - ] - } - }, - { - "name": "discovered_status_check", - "description": "A tool discovered via attributes.", - "inputSchema": { - "type": "object", - "properties": {} - } - } - ] -} diff --git a/tests/Inspector/Http/snapshots/HttpComplexToolSchemaTest-prompts_list.json b/tests/Inspector/Http/snapshots/HttpComplexToolSchemaTest-prompts_list.json deleted file mode 100644 index 7292222c..00000000 --- a/tests/Inspector/Http/snapshots/HttpComplexToolSchemaTest-prompts_list.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "prompts": [] -} diff --git a/tests/Inspector/Http/snapshots/HttpComplexToolSchemaTest-resources_list.json b/tests/Inspector/Http/snapshots/HttpComplexToolSchemaTest-resources_list.json deleted file mode 100644 index d02ef58d..00000000 --- a/tests/Inspector/Http/snapshots/HttpComplexToolSchemaTest-resources_list.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "resources": [] -} diff --git a/tests/Inspector/Http/snapshots/HttpComplexToolSchemaTest-resources_templates_list.json b/tests/Inspector/Http/snapshots/HttpComplexToolSchemaTest-resources_templates_list.json deleted file mode 100644 index e867d9d2..00000000 --- a/tests/Inspector/Http/snapshots/HttpComplexToolSchemaTest-resources_templates_list.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "resourceTemplates": [] -} diff --git a/tests/Inspector/Http/snapshots/HttpComplexToolSchemaTest-tools_call-schedule_event_all_day_reminder.json b/tests/Inspector/Http/snapshots/HttpComplexToolSchemaTest-tools_call-schedule_event_all_day_reminder.json deleted file mode 100644 index 26888e22..00000000 --- a/tests/Inspector/Http/snapshots/HttpComplexToolSchemaTest-tools_call-schedule_event_all_day_reminder.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "content": [ - { - "type": "text", - "text": "{\n \"success\": true,\n \"message\": \"Event \\\"Project Deadline\\\" scheduled successfully for \\\"2024-12-15\\\".\",\n \"event_details\": {\n \"title\": \"Project Deadline\",\n \"date\": \"2024-12-15\",\n \"type\": \"reminder\",\n \"time\": \"All day\",\n \"priority\": \"High\",\n \"attendees\": [],\n \"invites_will_be_sent\": false\n }\n}" - } - ], - "structuredContent": { - "success": true, - "message": "Event \"Project Deadline\" scheduled successfully for \"2024-12-15\".", - "event_details": { - "title": "Project Deadline", - "date": "2024-12-15", - "type": "reminder", - "time": "All day", - "priority": "High", - "attendees": [], - "invites_will_be_sent": false - } - }, - "isError": false -} diff --git a/tests/Inspector/Http/snapshots/HttpComplexToolSchemaTest-tools_call-schedule_event_high_priority.json b/tests/Inspector/Http/snapshots/HttpComplexToolSchemaTest-tools_call-schedule_event_high_priority.json deleted file mode 100644 index 23ee2994..00000000 --- a/tests/Inspector/Http/snapshots/HttpComplexToolSchemaTest-tools_call-schedule_event_high_priority.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "content": [ - { - "type": "text", - "text": "{\n \"success\": true,\n \"message\": \"Event \\\"Client Call\\\" scheduled successfully for \\\"2024-12-02\\\".\",\n \"event_details\": {\n \"title\": \"Client Call\",\n \"date\": \"2024-12-02\",\n \"type\": \"call\",\n \"time\": \"14:30\",\n \"priority\": \"High\",\n \"attendees\": [\n \"client@example.com\"\n ],\n \"invites_will_be_sent\": false\n }\n}" - } - ], - "structuredContent": { - "success": true, - "message": "Event \"Client Call\" scheduled successfully for \"2024-12-02\".", - "event_details": { - "title": "Client Call", - "date": "2024-12-02", - "type": "call", - "time": "14:30", - "priority": "High", - "attendees": [ - "client@example.com" - ], - "invites_will_be_sent": false - } - }, - "isError": false -} diff --git a/tests/Inspector/Http/snapshots/HttpComplexToolSchemaTest-tools_call-schedule_event_low_priority.json b/tests/Inspector/Http/snapshots/HttpComplexToolSchemaTest-tools_call-schedule_event_low_priority.json deleted file mode 100644 index 36980277..00000000 --- a/tests/Inspector/Http/snapshots/HttpComplexToolSchemaTest-tools_call-schedule_event_low_priority.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "content": [ - { - "type": "text", - "text": "{\n \"success\": true,\n \"message\": \"Event \\\"Office Party\\\" scheduled successfully for \\\"2024-12-20\\\".\",\n \"event_details\": {\n \"title\": \"Office Party\",\n \"date\": \"2024-12-20\",\n \"type\": \"other\",\n \"time\": \"18:00\",\n \"priority\": \"Low\",\n \"attendees\": [\n \"team@company.com\"\n ],\n \"invites_will_be_sent\": true\n }\n}" - } - ], - "structuredContent": { - "success": true, - "message": "Event \"Office Party\" scheduled successfully for \"2024-12-20\".", - "event_details": { - "title": "Office Party", - "date": "2024-12-20", - "type": "other", - "time": "18:00", - "priority": "Low", - "attendees": [ - "team@company.com" - ], - "invites_will_be_sent": true - } - }, - "isError": false -} diff --git a/tests/Inspector/Http/snapshots/HttpComplexToolSchemaTest-tools_call-schedule_event_meeting_with_time.json b/tests/Inspector/Http/snapshots/HttpComplexToolSchemaTest-tools_call-schedule_event_meeting_with_time.json deleted file mode 100644 index cf9d7b1d..00000000 --- a/tests/Inspector/Http/snapshots/HttpComplexToolSchemaTest-tools_call-schedule_event_meeting_with_time.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "content": [ - { - "type": "text", - "text": "{\n \"success\": true,\n \"message\": \"Event \\\"Team Standup\\\" scheduled successfully for \\\"2024-12-01\\\".\",\n \"event_details\": {\n \"title\": \"Team Standup\",\n \"date\": \"2024-12-01\",\n \"type\": \"meeting\",\n \"time\": \"09:00\",\n \"priority\": \"Normal\",\n \"attendees\": [\n \"alice@example.com\",\n \"bob@example.com\"\n ],\n \"invites_will_be_sent\": true\n }\n}" - } - ], - "structuredContent": { - "success": true, - "message": "Event \"Team Standup\" scheduled successfully for \"2024-12-01\".", - "event_details": { - "title": "Team Standup", - "date": "2024-12-01", - "type": "meeting", - "time": "09:00", - "priority": "Normal", - "attendees": [ - "alice@example.com", - "bob@example.com" - ], - "invites_will_be_sent": true - } - }, - "isError": false -} diff --git a/tests/Inspector/Http/snapshots/HttpComplexToolSchemaTest-tools_list.json b/tests/Inspector/Http/snapshots/HttpComplexToolSchemaTest-tools_list.json deleted file mode 100644 index 0e0bbe93..00000000 --- a/tests/Inspector/Http/snapshots/HttpComplexToolSchemaTest-tools_list.json +++ /dev/null @@ -1,70 +0,0 @@ -{ - "tools": [ - { - "name": "schedule_event", - "description": "Schedules a new event.\n\nThe inputSchema for this tool will reflect all parameter types and defaults.", - "inputSchema": { - "type": "object", - "properties": { - "title": { - "type": "string", - "description": "the title of the event" - }, - "date": { - "type": "string", - "description": "the date of the event (YYYY-MM-DD)" - }, - "type": { - "type": "string", - "description": "the type of event", - "enum": [ - "meeting", - "reminder", - "call", - "other" - ] - }, - "time": { - "type": [ - "null", - "string" - ], - "description": "the time of the event (HH:MM), optional", - "default": null - }, - "priority": { - "type": "string", - "description": "The priority of the event. Defaults to Normal.", - "default": "normal", - "enum": [ - "low", - "normal", - "high" - ] - }, - "attendees": { - "type": [ - "array", - "null" - ], - "description": "an optional list of attendee email addresses", - "default": null, - "items": { - "type": "string" - } - }, - "sendInvites": { - "type": "boolean", - "description": "send calendar invites to attendees? Defaults to true if attendees are provided", - "default": true - } - }, - "required": [ - "title", - "date", - "type" - ] - } - } - ] -} diff --git a/tests/Inspector/Http/snapshots/HttpDiscoveryUserProfileTest-prompts_get-generate_bio_prompt.json b/tests/Inspector/Http/snapshots/HttpDiscoveryUserProfileTest-prompts_get-generate_bio_prompt.json deleted file mode 100644 index 74dff36e..00000000 --- a/tests/Inspector/Http/snapshots/HttpDiscoveryUserProfileTest-prompts_get-generate_bio_prompt.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "messages": [ - { - "role": "user", - "content": { - "type": "text", - "text": "Write a short, formal biography for Alice (Role: admin, Email: alice@example.com). Highlight their role within the system." - } - } - ] -} diff --git a/tests/Inspector/Http/snapshots/HttpDiscoveryUserProfileTest-prompts_list.json b/tests/Inspector/Http/snapshots/HttpDiscoveryUserProfileTest-prompts_list.json deleted file mode 100644 index 3b140f83..00000000 --- a/tests/Inspector/Http/snapshots/HttpDiscoveryUserProfileTest-prompts_list.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "prompts": [ - { - "name": "generate_bio_prompt", - "description": "Generates a prompt to write a bio for a user.", - "arguments": [ - { - "name": "userId", - "description": "the user ID to generate the bio for", - "required": true - }, - { - "name": "tone", - "description": "Desired tone (e.g., 'formal', 'casual').", - "required": false - } - ] - } - ] -} diff --git a/tests/Inspector/Http/snapshots/HttpDiscoveryUserProfileTest-resources_list.json b/tests/Inspector/Http/snapshots/HttpDiscoveryUserProfileTest-resources_list.json deleted file mode 100644 index a9d91a86..00000000 --- a/tests/Inspector/Http/snapshots/HttpDiscoveryUserProfileTest-resources_list.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "resources": [ - { - "name": "system_status", - "uri": "system://status", - "description": "Current system status and runtime information", - "mimeType": "application/json" - }, - { - "name": "user_id_list", - "uri": "user://list/ids", - "description": "Provides a list of all available user IDs.", - "mimeType": "application/json" - } - ] -} diff --git a/tests/Inspector/Http/snapshots/HttpDiscoveryUserProfileTest-resources_read-read_user_id_list.json b/tests/Inspector/Http/snapshots/HttpDiscoveryUserProfileTest-resources_read-read_user_id_list.json deleted file mode 100644 index 04c7ae82..00000000 --- a/tests/Inspector/Http/snapshots/HttpDiscoveryUserProfileTest-resources_read-read_user_id_list.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "contents": [ - { - "uri": "user://list/ids", - "mimeType": "application/json", - "text": "[\n 101,\n 102,\n 103\n]" - } - ] -} diff --git a/tests/Inspector/Http/snapshots/HttpDiscoveryUserProfileTest-resources_read-read_user_profile_101.json b/tests/Inspector/Http/snapshots/HttpDiscoveryUserProfileTest-resources_read-read_user_profile_101.json deleted file mode 100644 index 39931e8c..00000000 --- a/tests/Inspector/Http/snapshots/HttpDiscoveryUserProfileTest-resources_read-read_user_profile_101.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "contents": [ - { - "uri": "user://101/profile", - "mimeType": "application/json", - "text": "{\n \"name\": \"Alice\",\n \"email\": \"alice@example.com\",\n \"role\": \"admin\"\n}" - } - ] -} diff --git a/tests/Inspector/Http/snapshots/HttpDiscoveryUserProfileTest-resources_read-read_user_profile_102.json b/tests/Inspector/Http/snapshots/HttpDiscoveryUserProfileTest-resources_read-read_user_profile_102.json deleted file mode 100644 index c3e1dcf8..00000000 --- a/tests/Inspector/Http/snapshots/HttpDiscoveryUserProfileTest-resources_read-read_user_profile_102.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "contents": [ - { - "uri": "user://102/profile", - "mimeType": "application/json", - "text": "{\n \"name\": \"Bob\",\n \"email\": \"bob@example.com\",\n \"role\": \"user\"\n}" - } - ] -} diff --git a/tests/Inspector/Http/snapshots/HttpDiscoveryUserProfileTest-resources_templates_list.json b/tests/Inspector/Http/snapshots/HttpDiscoveryUserProfileTest-resources_templates_list.json deleted file mode 100644 index c92be4ad..00000000 --- a/tests/Inspector/Http/snapshots/HttpDiscoveryUserProfileTest-resources_templates_list.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "resourceTemplates": [ - { - "name": "user_profile", - "uriTemplate": "user://{userId}/profile", - "description": "Get profile information for a specific user ID.", - "mimeType": "application/json" - } - ] -} diff --git a/tests/Inspector/Http/snapshots/HttpDiscoveryUserProfileTest-tools_call-lookup_user.json b/tests/Inspector/Http/snapshots/HttpDiscoveryUserProfileTest-tools_call-lookup_user.json deleted file mode 100644 index 1399cd26..00000000 --- a/tests/Inspector/Http/snapshots/HttpDiscoveryUserProfileTest-tools_call-lookup_user.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "content": [ - { - "type": "text", - "text": "Found user Alice (admin)." - }, - { - "name": "user_profile", - "uri": "user://101/profile", - "description": "Full profile for Alice.", - "mimeType": "application/json", - "type": "resource_link" - } - ], - "isError": false -} diff --git a/tests/Inspector/Http/snapshots/HttpDiscoveryUserProfileTest-tools_call-lookup_user_not_found.json b/tests/Inspector/Http/snapshots/HttpDiscoveryUserProfileTest-tools_call-lookup_user_not_found.json deleted file mode 100644 index 211521cf..00000000 --- a/tests/Inspector/Http/snapshots/HttpDiscoveryUserProfileTest-tools_call-lookup_user_not_found.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "content": [ - { - "type": "text", - "text": "User ID 999 not found." - } - ], - "isError": false -} diff --git a/tests/Inspector/Http/snapshots/HttpDiscoveryUserProfileTest-tools_call-send_welcome.json b/tests/Inspector/Http/snapshots/HttpDiscoveryUserProfileTest-tools_call-send_welcome.json deleted file mode 100644 index 29908263..00000000 --- a/tests/Inspector/Http/snapshots/HttpDiscoveryUserProfileTest-tools_call-send_welcome.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "content": [ - { - "type": "text", - "text": "{\n \"success\": true,\n \"message_sent\": \"Welcome, Alice! Welcome to our platform!\"\n}" - } - ], - "structuredContent": { - "success": true, - "message_sent": "Welcome, Alice! Welcome to our platform!" - }, - "isError": false -} diff --git a/tests/Inspector/Http/snapshots/HttpDiscoveryUserProfileTest-tools_call-test_tool_without_params.json b/tests/Inspector/Http/snapshots/HttpDiscoveryUserProfileTest-tools_call-test_tool_without_params.json deleted file mode 100644 index d680ebab..00000000 --- a/tests/Inspector/Http/snapshots/HttpDiscoveryUserProfileTest-tools_call-test_tool_without_params.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "content": [ - { - "type": "text", - "text": "{\n \"success\": true,\n \"message\": \"Test tool without params\"\n}" - } - ], - "structuredContent": { - "success": true, - "message": "Test tool without params" - }, - "isError": false -} diff --git a/tests/Inspector/Http/snapshots/HttpDiscoveryUserProfileTest-tools_list.json b/tests/Inspector/Http/snapshots/HttpDiscoveryUserProfileTest-tools_list.json deleted file mode 100644 index 08b8198d..00000000 --- a/tests/Inspector/Http/snapshots/HttpDiscoveryUserProfileTest-tools_list.json +++ /dev/null @@ -1,74 +0,0 @@ -{ - "tools": [ - { - "name": "calculator", - "description": "Perform basic math operations (add, subtract, multiply, divide)", - "inputSchema": { - "type": "object", - "properties": { - "a": { - "type": "number" - }, - "b": { - "type": "number" - }, - "operation": { - "type": "string", - "default": "add" - } - }, - "required": [ - "a", - "b" - ] - } - }, - { - "name": "lookup_user", - "description": "Looks up a user and returns a reference to their profile resource.\n\nRather than embedding the full profile (as `resources/read` on\n`user://{userId}/profile` would), this returns a `resource_link` block\npointing at that resource template so the caller can fetch it\nseparately if needed. This mirrors how a tool like a search returning\nmany hits would reference each matching resource by URI instead of\ninlining every one of them.", - "inputSchema": { - "type": "object", - "properties": { - "userId": { - "type": "string", - "description": "the ID of the user to look up" - } - }, - "required": [ - "userId" - ] - } - }, - { - "name": "send_welcome", - "description": "Sends a welcome message to a user.\n\n(This is a placeholder - in a real app, it might queue an email).", - "inputSchema": { - "type": "object", - "properties": { - "userId": { - "type": "string", - "description": "the ID of the user to message" - }, - "customMessage": { - "type": [ - "null", - "string" - ], - "description": "an optional custom message part", - "default": null - } - }, - "required": [ - "userId" - ] - } - }, - { - "name": "test_tool_without_params", - "inputSchema": { - "type": "object", - "properties": {} - } - } - ] -} diff --git a/tests/Inspector/Http/snapshots/HttpSchemaShowcaseTest-prompts_list.json b/tests/Inspector/Http/snapshots/HttpSchemaShowcaseTest-prompts_list.json deleted file mode 100644 index 7292222c..00000000 --- a/tests/Inspector/Http/snapshots/HttpSchemaShowcaseTest-prompts_list.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "prompts": [] -} diff --git a/tests/Inspector/Http/snapshots/HttpSchemaShowcaseTest-resources_list.json b/tests/Inspector/Http/snapshots/HttpSchemaShowcaseTest-resources_list.json deleted file mode 100644 index d02ef58d..00000000 --- a/tests/Inspector/Http/snapshots/HttpSchemaShowcaseTest-resources_list.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "resources": [] -} diff --git a/tests/Inspector/Http/snapshots/HttpSchemaShowcaseTest-resources_templates_list.json b/tests/Inspector/Http/snapshots/HttpSchemaShowcaseTest-resources_templates_list.json deleted file mode 100644 index e867d9d2..00000000 --- a/tests/Inspector/Http/snapshots/HttpSchemaShowcaseTest-resources_templates_list.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "resourceTemplates": [] -} diff --git a/tests/Inspector/Http/snapshots/HttpSchemaShowcaseTest-tools_call-calculate_range.json b/tests/Inspector/Http/snapshots/HttpSchemaShowcaseTest-tools_call-calculate_range.json deleted file mode 100644 index d21488a0..00000000 --- a/tests/Inspector/Http/snapshots/HttpSchemaShowcaseTest-tools_call-calculate_range.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "content": [ - { - "type": "text", - "text": "{\n \"result\": 50,\n \"operation\": \"10 multiply 5\",\n \"precision\": 2,\n \"within_bounds\": true\n}" - } - ], - "structuredContent": { - "result": 50, - "operation": "10 multiply 5", - "precision": 2, - "within_bounds": true - }, - "isError": false -} diff --git a/tests/Inspector/Http/snapshots/HttpSchemaShowcaseTest-tools_call-format_text.json b/tests/Inspector/Http/snapshots/HttpSchemaShowcaseTest-tools_call-format_text.json deleted file mode 100644 index c14b72a5..00000000 --- a/tests/Inspector/Http/snapshots/HttpSchemaShowcaseTest-tools_call-format_text.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "content": [ - { - "type": "text", - "text": "{\n \"original\": \"Hello World Test\",\n \"formatted\": \"HELLO WORLD TEST\",\n \"length\": 16,\n \"format_applied\": \"uppercase\"\n}" - } - ], - "structuredContent": { - "original": "Hello World Test", - "formatted": "HELLO WORLD TEST", - "length": 16, - "format_applied": "uppercase" - }, - "isError": false -} diff --git a/tests/Inspector/Http/snapshots/HttpSchemaShowcaseTest-tools_call-generate_config.json b/tests/Inspector/Http/snapshots/HttpSchemaShowcaseTest-tools_call-generate_config.json deleted file mode 100644 index 37b4540a..00000000 --- a/tests/Inspector/Http/snapshots/HttpSchemaShowcaseTest-tools_call-generate_config.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "content": [ - { - "type": "text", - "text": "{\n \"success\": true,\n \"config\": {\n \"app\": {\n \"name\": \"TestApp\",\n \"env\": \"development\",\n \"debug\": true,\n \"url\": \"https://example.com\",\n \"port\": 8080\n },\n \"generated_at\": \"2025-01-01T00:00:00+00:00\",\n \"version\": \"1.0.0\",\n \"features\": {\n \"logging\": true,\n \"caching\": false,\n \"analytics\": false,\n \"rate_limiting\": false\n }\n },\n \"validation\": {\n \"app_name_valid\": true,\n \"url_valid\": true,\n \"port_in_range\": true\n }\n}" - } - ], - "structuredContent": { - "success": true, - "config": { - "app": { - "name": "TestApp", - "env": "development", - "debug": true, - "url": "https://example.com", - "port": 8080 - }, - "generated_at": "2025-01-01T00:00:00+00:00", - "version": "1.0.0", - "features": { - "logging": true, - "caching": false, - "analytics": false, - "rate_limiting": false - } - }, - "validation": { - "app_name_valid": true, - "url_valid": true, - "port_in_range": true - } - }, - "isError": false -} diff --git a/tests/Inspector/Http/snapshots/HttpSchemaShowcaseTest-tools_call-manage_list.json b/tests/Inspector/Http/snapshots/HttpSchemaShowcaseTest-tools_call-manage_list.json deleted file mode 100644 index 18c414ec..00000000 --- a/tests/Inspector/Http/snapshots/HttpSchemaShowcaseTest-tools_call-manage_list.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "content": [ - { - "type": "text", - "text": "{\n \"original_count\": 4,\n \"processed_count\": 4,\n \"action\": \"sort\",\n \"original\": [\n \"apple\",\n \"banana\",\n \"cherry\",\n \"date\"\n ],\n \"processed\": [\n \"apple\",\n \"banana\",\n \"cherry\",\n \"date\"\n ],\n \"stats\": {\n \"average_length\": 5.25,\n \"shortest\": 4,\n \"longest\": 6\n }\n}" - } - ], - "structuredContent": { - "original_count": 4, - "processed_count": 4, - "action": "sort", - "original": [ - "apple", - "banana", - "cherry", - "date" - ], - "processed": [ - "apple", - "banana", - "cherry", - "date" - ], - "stats": { - "average_length": 5.25, - "shortest": 4, - "longest": 6 - } - }, - "isError": false -} diff --git a/tests/Inspector/Http/snapshots/HttpSchemaShowcaseTest-tools_call-schedule_event.json b/tests/Inspector/Http/snapshots/HttpSchemaShowcaseTest-tools_call-schedule_event.json deleted file mode 100644 index f597105d..00000000 --- a/tests/Inspector/Http/snapshots/HttpSchemaShowcaseTest-tools_call-schedule_event.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "content": [ - { - "type": "text", - "text": "{\n \"success\": true,\n \"event\": {\n \"id\": \"event_test123456789\",\n \"title\": \"Team Meeting\",\n \"start_time\": \"2025-01-01T00:00:00+00:00\",\n \"end_time\": \"2025-01-01T00:00:00+00:00\",\n \"duration_hours\": 1.5,\n \"priority\": \"high\",\n \"attendees\": [\n \"alice@example.com\",\n \"bob@example.com\"\n ],\n \"created_at\": \"2025-01-01T00:00:00+00:00\"\n },\n \"info\": {\n \"attendee_count\": 2,\n \"is_all_day\": false,\n \"is_future\": false,\n \"timezone_note\": \"Times are in UTC\"\n }\n}" - } - ], - "structuredContent": { - "success": true, - "event": { - "id": "event_test123456789", - "title": "Team Meeting", - "start_time": "2025-01-01T00:00:00+00:00", - "end_time": "2025-01-01T00:00:00+00:00", - "duration_hours": 1.5, - "priority": "high", - "attendees": [ - "alice@example.com", - "bob@example.com" - ], - "created_at": "2025-01-01T00:00:00+00:00" - }, - "info": { - "attendee_count": 2, - "is_all_day": false, - "is_future": false, - "timezone_note": "Times are in UTC" - } - }, - "isError": false -} diff --git a/tests/Inspector/Http/snapshots/HttpSchemaShowcaseTest-tools_call-validate_profile.json b/tests/Inspector/Http/snapshots/HttpSchemaShowcaseTest-tools_call-validate_profile.json deleted file mode 100644 index e87f6cbe..00000000 --- a/tests/Inspector/Http/snapshots/HttpSchemaShowcaseTest-tools_call-validate_profile.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "content": [ - { - "type": "text", - "text": "{\n \"valid\": true,\n \"profile\": {\n \"name\": \"John Doe\",\n \"email\": \"john@example.com\",\n \"age\": 30,\n \"role\": \"user\"\n },\n \"errors\": [],\n \"warnings\": [],\n \"processed_at\": \"2025-01-01 00:00:00\"\n}" - } - ], - "structuredContent": { - "valid": true, - "profile": { - "name": "John Doe", - "email": "john@example.com", - "age": 30, - "role": "user" - }, - "errors": [], - "warnings": [], - "processed_at": "2025-01-01 00:00:00" - }, - "isError": false -} diff --git a/tests/Inspector/Http/snapshots/HttpSchemaShowcaseTest-tools_list.json b/tests/Inspector/Http/snapshots/HttpSchemaShowcaseTest-tools_list.json deleted file mode 100644 index f79bfeba..00000000 --- a/tests/Inspector/Http/snapshots/HttpSchemaShowcaseTest-tools_list.json +++ /dev/null @@ -1,284 +0,0 @@ -{ - "tools": [ - { - "name": "format_text", - "description": "Formats text with validation constraints. Text must be 5-100 characters and contain only letters, numbers, spaces, and basic punctuation.", - "inputSchema": { - "type": "object", - "properties": { - "text": { - "type": "string", - "description": "The text to format", - "minLength": 5, - "maxLength": 100, - "pattern": "^[a-zA-Z0-9\\s\\.,!?\\-]+$" - }, - "format": { - "type": "string", - "default": "sentence", - "description": "Format style", - "enum": [ - "uppercase", - "lowercase", - "title", - "sentence" - ] - } - }, - "required": [ - "text" - ] - } - }, - { - "name": "calculate_range", - "description": "Performs mathematical operations with numeric constraints.\n\nDemonstrates: METHOD-LEVEL Schema", - "inputSchema": { - "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", - "default": 2, - "description": "Decimal precision (must be multiple of 2, between 0-10)", - "minimum": 0, - "maximum": 10, - "multipleOf": 2 - } - }, - "required": [ - "first", - "second", - "operation" - ] - } - }, - { - "name": "validate_profile", - "description": "Validates and processes user profile data with strict schema requirements.", - "inputSchema": { - "type": "object", - "properties": { - "profile": { - "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 - } - }, - "required": [ - "profile" - ] - } - }, - { - "name": "manage_list", - "description": "Manages a list of items with size and uniqueness constraints.", - "inputSchema": { - "type": "object", - "properties": { - "items": { - "type": "array", - "items": { - "type": "string", - "minLength": 1, - "maxLength": 30 - }, - "description": "List of items to manage (2-10 unique strings)", - "minItems": 2, - "maxItems": 10, - "uniqueItems": true - }, - "action": { - "type": "string", - "default": "sort", - "description": "Action to perform on the list", - "enum": [ - "sort", - "reverse", - "shuffle", - "deduplicate", - "filter_short", - "filter_long" - ] - } - }, - "required": [ - "items" - ] - } - }, - { - "name": "generate_config", - "description": "Generates configuration with format-validated inputs.", - "inputSchema": { - "type": "object", - "properties": { - "appName": { - "type": "string", - "description": "Application name (alphanumeric with hyphens)", - "minLength": 3, - "maxLength": 20, - "pattern": "^[a-zA-Z0-9\\-]+$" - }, - "baseUrl": { - "type": "string", - "description": "Valid URL for the application", - "format": "uri" - }, - "environment": { - "type": "string", - "default": "development", - "description": "Environment type", - "enum": [ - "development", - "staging", - "production" - ] - }, - "debug": { - "type": "boolean", - "default": true, - "description": "Enable debug mode" - }, - "port": { - "type": "integer", - "default": 8080, - "description": "Port number (1024-65535)", - "minimum": 1024, - "maximum": 65535 - } - }, - "required": [ - "appName", - "baseUrl" - ] - } - }, - { - "name": "schedule_event", - "description": "Schedules an event with time validation and constraints.", - "inputSchema": { - "type": "object", - "properties": { - "title": { - "type": "string", - "description": "Event title (3-50 characters)", - "minLength": 3, - "maxLength": 50 - }, - "startTime": { - "type": "string", - "description": "Event start time in ISO 8601 format", - "format": "date-time" - }, - "durationHours": { - "type": "number", - "description": "Duration in hours (minimum 0.5, maximum 24)", - "minimum": 0.5, - "maximum": 24, - "multipleOf": 0.5 - }, - "priority": { - "type": "string", - "default": "medium", - "description": "Event priority level", - "enum": [ - "low", - "medium", - "high", - "urgent" - ] - }, - "attendees": { - "type": "array", - "default": [], - "items": { - "type": "string", - "format": "email" - }, - "description": "List of attendee email addresses", - "maxItems": 20 - } - }, - "required": [ - "title", - "startTime", - "durationHours" - ] - } - } - ] -} diff --git a/tests/Inspector/InspectorSnapshotTestCase.php b/tests/Inspector/InspectorSnapshotTestCase.php deleted file mode 100644 index 35d08e1d..00000000 --- a/tests/Inspector/InspectorSnapshotTestCase.php +++ /dev/null @@ -1,137 +0,0 @@ - $options */ - #[DataProvider('provideMethods')] - public function testOutputMatchesSnapshot( - string $method, - array $options = [], - ?string $testName = null, - ): void { - $inspector = \sprintf('@modelcontextprotocol/inspector@%s', self::INSPECTOR_VERSION); - - $args = [ - 'npx', - $inspector, - '--cli', - ...$this->getServerConnectionArgs(), - '--transport', - $this->getTransport(), - '--method', - $method, - ]; - - // Options for tools/call - if (isset($options['toolName'])) { - $args[] = '--tool-name'; - $args[] = $options['toolName']; - - foreach ($options['toolArgs'] ?? [] as $key => $value) { - $args[] = '--tool-arg'; - if (\is_array($value)) { - $args[] = \sprintf('%s=%s', $key, json_encode($value)); - } elseif (\is_bool($value)) { - $args[] = \sprintf('%s=%s', $key, $value ? 'true' : 'false'); - } else { - $args[] = \sprintf('%s=%s', $key, $value); - } - } - } - - // Options for resources/read - if (isset($options['uri'])) { - $args[] = '--uri'; - $args[] = $options['uri']; - } - - // Options for prompts/get - if (isset($options['promptName'])) { - $args[] = '--prompt-name'; - $args[] = $options['promptName']; - - foreach ($options['promptArgs'] ?? [] as $key => $value) { - $args[] = '--prompt-args'; - if (\is_array($value)) { - $args[] = \sprintf('%s=%s', $key, json_encode($value)); - } elseif (\is_bool($value)) { - $args[] = \sprintf('%s=%s', $key, $value ? 'true' : 'false'); - } else { - $args[] = \sprintf('%s=%s', $key, $value); - } - } - } - - // Options for logging/setLevel - if (isset($options['logLevel'])) { - $args[] = '--log-level'; - $args[] = $options['logLevel'] instanceof LoggingLevel ? $options['logLevel']->value : $options['logLevel']; - } - - // Options for env variables - if (isset($options['envVars'])) { - foreach ($options['envVars'] as $key => $value) { - $args[] = '-e'; - $args[] = \sprintf('%s=%s', $key, $value); - } - } - - $output = (new Process(command: $args)) - ->mustRun() - ->getOutput(); - - $snapshotFile = $this->getSnapshotFilePath($method, $testName); - - $normalizedOutput = $this->normalizeTestOutput($output, $testName); - - if (!file_exists($snapshotFile)) { - file_put_contents($snapshotFile, $normalizedOutput.\PHP_EOL); - $this->markTestIncomplete("Snapshot created at $snapshotFile, please re-run tests."); - } - - $expected = file_get_contents($snapshotFile); - - $message = \sprintf('Output does not match snapshot "%s".', $snapshotFile); - $this->assertJsonStringEqualsJsonString($expected, $normalizedOutput, $message); - } - - protected function normalizeTestOutput(string $output, ?string $testName = null): string - { - return $output; - } - - public static function provideMethods(): array - { - return [ - 'Prompt Listing' => ['method' => 'prompts/list'], - 'Resource Listing' => ['method' => 'resources/list'], - 'Resource Template Listing' => ['method' => 'resources/templates/list'], - 'Tool Listing' => ['method' => 'tools/list'], - ]; - } - - abstract protected function getSnapshotFilePath(string $method, ?string $testName = null): string; - - /** @return array */ - abstract protected function getServerConnectionArgs(): array; - - abstract protected function getTransport(): string; -} diff --git a/tests/Inspector/Stdio/StdioCachedDiscoveryTest.php b/tests/Inspector/Stdio/StdioCachedDiscoveryTest.php deleted file mode 100644 index fd0bc908..00000000 --- a/tests/Inspector/Stdio/StdioCachedDiscoveryTest.php +++ /dev/null @@ -1,93 +0,0 @@ - [ - 'method' => 'tools/call', - 'options' => [ - 'toolName' => 'add_numbers', - 'toolArgs' => ['a' => 5, 'b' => 3], - ], - 'testName' => 'add_numbers', - ], - 'Add Numbers (Negative)' => [ - 'method' => 'tools/call', - 'options' => [ - 'toolName' => 'add_numbers', - 'toolArgs' => ['a' => -10, 'b' => 7], - ], - 'testName' => 'add_numbers_negative', - ], - 'Multiply Numbers Tool' => [ - 'method' => 'tools/call', - 'options' => [ - 'toolName' => 'multiply_numbers', - 'toolArgs' => ['a' => 4, 'b' => 6], - ], - 'testName' => 'multiply_numbers', - ], - 'Multiply Numbers (Zero)' => [ - 'method' => 'tools/call', - 'options' => [ - 'toolName' => 'multiply_numbers', - 'toolArgs' => ['a' => 15, 'b' => 0], - ], - 'testName' => 'multiply_numbers_zero', - ], - 'Divide Numbers Tool' => [ - 'method' => 'tools/call', - 'options' => [ - 'toolName' => 'divide_numbers', - 'toolArgs' => ['a' => 20, 'b' => 4], - ], - 'testName' => 'divide_numbers', - ], - 'Divide Numbers (Decimal Result)' => [ - 'method' => 'tools/call', - 'options' => [ - 'toolName' => 'divide_numbers', - 'toolArgs' => ['a' => 7, 'b' => 2], - ], - 'testName' => 'divide_numbers_decimal', - ], - 'Power Tool' => [ - 'method' => 'tools/call', - 'options' => [ - 'toolName' => 'power', - 'toolArgs' => ['base' => 2, 'exponent' => 8], - ], - 'testName' => 'power', - ], - 'Power Tool (Zero Exponent)' => [ - 'method' => 'tools/call', - 'options' => [ - 'toolName' => 'power', - 'toolArgs' => ['base' => 5, 'exponent' => 0], - ], - 'testName' => 'power_zero_exponent', - ], - ]; - } - - protected function getServerScript(): string - { - return \dirname(__DIR__, 3).'/examples/server/cached-discovery/server.php'; - } -} diff --git a/tests/Inspector/Stdio/StdioCustomDependenciesTest.php b/tests/Inspector/Stdio/StdioCustomDependenciesTest.php deleted file mode 100644 index 9e1affd0..00000000 --- a/tests/Inspector/Stdio/StdioCustomDependenciesTest.php +++ /dev/null @@ -1,77 +0,0 @@ - [ - 'method' => 'tools/call', - 'options' => [ - 'toolName' => 'add_task', - 'toolArgs' => ['userId' => 'alice', 'description' => 'Complete the project documentation'], - ], - 'testName' => 'add_task', - ], - 'List User Tasks' => [ - 'method' => 'tools/call', - 'options' => [ - 'toolName' => 'list_user_tasks', - 'toolArgs' => ['userId' => 'alice'], - ], - 'testName' => 'list_user_tasks', - ], - 'Complete Task' => [ - 'method' => 'tools/call', - 'options' => [ - 'toolName' => 'complete_task', - 'toolArgs' => ['taskId' => 1], - ], - 'testName' => 'complete_task', - ], - 'Read System Statistics Resource' => [ - 'method' => 'resources/read', - 'options' => [ - 'uri' => 'stats://system/overview', - ], - 'testName' => 'read_system_stats', - ], - ]; - } - - protected function getServerScript(): string - { - return \dirname(__DIR__, 3).'/examples/server/custom-dependencies/server.php'; - } - - protected function normalizeTestOutput(string $output, ?string $testName = null): string - { - return match ($testName) { - 'add_task' => preg_replace( - '/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}[+-]\d{2}:\d{2}/', - '2025-01-01T00:00:00+00:00', - $output - ), - 'read_system_stats' => preg_replace( - '/\\\\"server_uptime_seconds\\\\": -?\d+\.?\d*/', - '\\"server_uptime_seconds\\": 12345', - $output - ), - default => $output, - }; - } -} diff --git a/tests/Inspector/Stdio/StdioDiscoveryCalculatorTest.php b/tests/Inspector/Stdio/StdioDiscoveryCalculatorTest.php deleted file mode 100644 index f44ed6cc..00000000 --- a/tests/Inspector/Stdio/StdioDiscoveryCalculatorTest.php +++ /dev/null @@ -1,50 +0,0 @@ - [ - 'method' => 'tools/call', - 'options' => [ - 'toolName' => 'calculate', - 'toolArgs' => ['a' => 12.5, 'b' => 7.3, 'operation' => 'add'], - ], - 'testName' => 'calculate_sum', - ], - 'Update Setting' => [ - 'method' => 'tools/call', - 'options' => [ - 'toolName' => 'update_setting', - 'toolArgs' => ['setting' => 'precision', 'value' => 3], - ], - 'testName' => 'update_setting', - ], - 'Read Config' => [ - 'method' => 'resources/read', - 'options' => [ - 'uri' => 'config://calculator/settings', - ], - 'testName' => 'read_config', - ], - ]; - } - - protected function getServerScript(): string - { - return \dirname(__DIR__, 3).'/examples/server/discovery-calculator/server.php'; - } -} diff --git a/tests/Inspector/Stdio/StdioEnvVariablesTest.php b/tests/Inspector/Stdio/StdioEnvVariablesTest.php deleted file mode 100644 index 1c87469c..00000000 --- a/tests/Inspector/Stdio/StdioEnvVariablesTest.php +++ /dev/null @@ -1,55 +0,0 @@ - [ - 'method' => 'tools/call', - 'options' => [ - 'toolName' => 'process_data_by_mode', - 'toolArgs' => ['input' => 'test data'], - ], - 'testName' => 'process_data_default', - ], - 'Process Data (Debug Mode)' => [ - 'method' => 'tools/call', - 'options' => [ - 'toolName' => 'process_data_by_mode', - 'toolArgs' => ['input' => 'debug test'], - 'envVars' => ['APP_MODE' => 'debug'], - ], - 'testName' => 'process_data_debug', - ], - 'Process Data (Production Mode)' => [ - 'method' => 'tools/call', - 'options' => [ - 'toolName' => 'process_data_by_mode', - 'toolArgs' => ['input' => 'production data'], - 'envVars' => ['APP_MODE' => 'production'], - ], - 'testName' => 'process_data_production', - ], - ]; - } - - protected function getServerScript(): string - { - return \dirname(__DIR__, 3).'/examples/server/env-variables/server.php'; - } -} diff --git a/tests/Inspector/Stdio/StdioExplicitRegistrationTest.php b/tests/Inspector/Stdio/StdioExplicitRegistrationTest.php deleted file mode 100644 index d31c3f24..00000000 --- a/tests/Inspector/Stdio/StdioExplicitRegistrationTest.php +++ /dev/null @@ -1,82 +0,0 @@ - [ - 'method' => 'tools/call', - 'options' => [ - 'toolName' => 'echo_text', - 'toolArgs' => ['text' => 'Hello World!'], - ], - 'testName' => 'echo_text', - ], - 'Echo Tool with Special Characters' => [ - 'method' => 'tools/call', - 'options' => [ - 'toolName' => 'echo_text', - 'toolArgs' => ['text' => 'Test with emoji 🎉 and symbols @#$%'], - ], - 'testName' => 'echo_text_special_chars', - ], - 'Read App Version Resource' => [ - 'method' => 'resources/read', - 'options' => [ - 'uri' => 'app://version', - ], - 'testName' => 'read_app_version', - ], - 'Read Item Details (123)' => [ - 'method' => 'resources/read', - 'options' => [ - 'uri' => 'item://123/details', - ], - 'testName' => 'read_item_123_details', - ], - 'Read Item Details (ABC)' => [ - 'method' => 'resources/read', - 'options' => [ - 'uri' => 'item://ABC/details', - ], - 'testName' => 'read_item_ABC_details', - ], - 'Personalized Greeting Prompt (Alice)' => [ - 'method' => 'prompts/get', - 'options' => [ - 'promptName' => 'personalized_greeting', - 'promptArgs' => ['userName' => 'Alice'], - ], - 'testName' => 'personalized_greeting_alice', - ], - 'Personalized Greeting Prompt (Bob)' => [ - 'method' => 'prompts/get', - 'options' => [ - 'promptName' => 'personalized_greeting', - 'promptArgs' => ['userName' => 'Bob'], - ], - 'testName' => 'personalized_greeting_bob', - ], - ]; - } - - protected function getServerScript(): string - { - return \dirname(__DIR__, 3).'/examples/server/explicit-registration/server.php'; - } -} diff --git a/tests/Inspector/Stdio/StdioInspectorSnapshotTestCase.php b/tests/Inspector/Stdio/StdioInspectorSnapshotTestCase.php deleted file mode 100644 index a88896c3..00000000 --- a/tests/Inspector/Stdio/StdioInspectorSnapshotTestCase.php +++ /dev/null @@ -1,37 +0,0 @@ -getServerScript()]; - } - - protected function getTransport(): string - { - return 'stdio'; - } - - protected function getSnapshotFilePath(string $method, ?string $testName = null): string - { - $className = substr(static::class, strrpos(static::class, '\\') + 1); - $suffix = $testName ? '-'.preg_replace('/[^a-zA-Z0-9_]/', '_', $testName) : ''; - - return __DIR__.'/snapshots/'.$className.'-'.str_replace('/', '_', $method).$suffix.'.json'; - } -} diff --git a/tests/Inspector/Stdio/StdioMcpAppsTest.php b/tests/Inspector/Stdio/StdioMcpAppsTest.php deleted file mode 100644 index b8f2a6fc..00000000 --- a/tests/Inspector/Stdio/StdioMcpAppsTest.php +++ /dev/null @@ -1,52 +0,0 @@ - [ - 'method' => 'resources/read', - 'options' => [ - 'uri' => 'ui://weather-app', - ], - 'testName' => 'read_weather_ui', - ], - 'Get Weather Tool Call (London)' => [ - 'method' => 'tools/call', - 'options' => [ - 'toolName' => 'get_weather', - 'toolArgs' => ['city' => 'London'], - ], - 'testName' => 'get_weather_london', - ], - 'Get Weather Tool Call (Tokyo)' => [ - 'method' => 'tools/call', - 'options' => [ - 'toolName' => 'get_weather', - 'toolArgs' => ['city' => 'Tokyo'], - ], - 'testName' => 'get_weather_tokyo', - ], - ]; - } - - protected function getServerScript(): string - { - return \dirname(__DIR__, 3).'/examples/server/mcp-apps/server.php'; - } -} diff --git a/tests/Inspector/Stdio/snapshots/StdioCachedDiscoveryTest-prompts_list.json b/tests/Inspector/Stdio/snapshots/StdioCachedDiscoveryTest-prompts_list.json deleted file mode 100644 index 7292222c..00000000 --- a/tests/Inspector/Stdio/snapshots/StdioCachedDiscoveryTest-prompts_list.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "prompts": [] -} diff --git a/tests/Inspector/Stdio/snapshots/StdioCachedDiscoveryTest-resources_list.json b/tests/Inspector/Stdio/snapshots/StdioCachedDiscoveryTest-resources_list.json deleted file mode 100644 index d02ef58d..00000000 --- a/tests/Inspector/Stdio/snapshots/StdioCachedDiscoveryTest-resources_list.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "resources": [] -} diff --git a/tests/Inspector/Stdio/snapshots/StdioCachedDiscoveryTest-resources_templates_list.json b/tests/Inspector/Stdio/snapshots/StdioCachedDiscoveryTest-resources_templates_list.json deleted file mode 100644 index e867d9d2..00000000 --- a/tests/Inspector/Stdio/snapshots/StdioCachedDiscoveryTest-resources_templates_list.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "resourceTemplates": [] -} diff --git a/tests/Inspector/Stdio/snapshots/StdioCachedDiscoveryTest-tools_call-add_numbers.json b/tests/Inspector/Stdio/snapshots/StdioCachedDiscoveryTest-tools_call-add_numbers.json deleted file mode 100644 index 3bb28b3d..00000000 --- a/tests/Inspector/Stdio/snapshots/StdioCachedDiscoveryTest-tools_call-add_numbers.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "content": [ - { - "type": "text", - "text": "8" - } - ], - "isError": false -} diff --git a/tests/Inspector/Stdio/snapshots/StdioCachedDiscoveryTest-tools_call-add_numbers_negative.json b/tests/Inspector/Stdio/snapshots/StdioCachedDiscoveryTest-tools_call-add_numbers_negative.json deleted file mode 100644 index 2a25b87b..00000000 --- a/tests/Inspector/Stdio/snapshots/StdioCachedDiscoveryTest-tools_call-add_numbers_negative.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "content": [ - { - "type": "text", - "text": "-3" - } - ], - "isError": false -} diff --git a/tests/Inspector/Stdio/snapshots/StdioCachedDiscoveryTest-tools_call-divide_numbers.json b/tests/Inspector/Stdio/snapshots/StdioCachedDiscoveryTest-tools_call-divide_numbers.json deleted file mode 100644 index 957d6df4..00000000 --- a/tests/Inspector/Stdio/snapshots/StdioCachedDiscoveryTest-tools_call-divide_numbers.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "content": [ - { - "type": "text", - "text": "5" - } - ], - "isError": false -} diff --git a/tests/Inspector/Stdio/snapshots/StdioCachedDiscoveryTest-tools_call-divide_numbers_decimal.json b/tests/Inspector/Stdio/snapshots/StdioCachedDiscoveryTest-tools_call-divide_numbers_decimal.json deleted file mode 100644 index 1ae0005d..00000000 --- a/tests/Inspector/Stdio/snapshots/StdioCachedDiscoveryTest-tools_call-divide_numbers_decimal.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "content": [ - { - "type": "text", - "text": "3.5" - } - ], - "isError": false -} diff --git a/tests/Inspector/Stdio/snapshots/StdioCachedDiscoveryTest-tools_call-multiply_numbers.json b/tests/Inspector/Stdio/snapshots/StdioCachedDiscoveryTest-tools_call-multiply_numbers.json deleted file mode 100644 index b391c653..00000000 --- a/tests/Inspector/Stdio/snapshots/StdioCachedDiscoveryTest-tools_call-multiply_numbers.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "content": [ - { - "type": "text", - "text": "24" - } - ], - "isError": false -} diff --git a/tests/Inspector/Stdio/snapshots/StdioCachedDiscoveryTest-tools_call-multiply_numbers_zero.json b/tests/Inspector/Stdio/snapshots/StdioCachedDiscoveryTest-tools_call-multiply_numbers_zero.json deleted file mode 100644 index 04988535..00000000 --- a/tests/Inspector/Stdio/snapshots/StdioCachedDiscoveryTest-tools_call-multiply_numbers_zero.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "content": [ - { - "type": "text", - "text": "0" - } - ], - "isError": false -} diff --git a/tests/Inspector/Stdio/snapshots/StdioCachedDiscoveryTest-tools_call-power.json b/tests/Inspector/Stdio/snapshots/StdioCachedDiscoveryTest-tools_call-power.json deleted file mode 100644 index d4289e41..00000000 --- a/tests/Inspector/Stdio/snapshots/StdioCachedDiscoveryTest-tools_call-power.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "content": [ - { - "type": "text", - "text": "256" - } - ], - "isError": false -} diff --git a/tests/Inspector/Stdio/snapshots/StdioCachedDiscoveryTest-tools_call-power_zero_exponent.json b/tests/Inspector/Stdio/snapshots/StdioCachedDiscoveryTest-tools_call-power_zero_exponent.json deleted file mode 100644 index 5088e95f..00000000 --- a/tests/Inspector/Stdio/snapshots/StdioCachedDiscoveryTest-tools_call-power_zero_exponent.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "content": [ - { - "type": "text", - "text": "1" - } - ], - "isError": false -} diff --git a/tests/Inspector/Stdio/snapshots/StdioCachedDiscoveryTest-tools_list.json b/tests/Inspector/Stdio/snapshots/StdioCachedDiscoveryTest-tools_list.json deleted file mode 100644 index 60848ab1..00000000 --- a/tests/Inspector/Stdio/snapshots/StdioCachedDiscoveryTest-tools_list.json +++ /dev/null @@ -1,76 +0,0 @@ -{ - "tools": [ - { - "name": "add_numbers", - "inputSchema": { - "type": "object", - "properties": { - "a": { - "type": "integer" - }, - "b": { - "type": "integer" - } - }, - "required": [ - "a", - "b" - ] - } - }, - { - "name": "multiply_numbers", - "inputSchema": { - "type": "object", - "properties": { - "a": { - "type": "integer" - }, - "b": { - "type": "integer" - } - }, - "required": [ - "a", - "b" - ] - } - }, - { - "name": "divide_numbers", - "inputSchema": { - "type": "object", - "properties": { - "a": { - "type": "integer" - }, - "b": { - "type": "integer" - } - }, - "required": [ - "a", - "b" - ] - } - }, - { - "name": "power", - "inputSchema": { - "type": "object", - "properties": { - "base": { - "type": "integer" - }, - "exponent": { - "type": "integer" - } - }, - "required": [ - "base", - "exponent" - ] - } - } - ] -} diff --git a/tests/Inspector/Stdio/snapshots/StdioCustomDependenciesTest-prompts_list.json b/tests/Inspector/Stdio/snapshots/StdioCustomDependenciesTest-prompts_list.json deleted file mode 100644 index 7292222c..00000000 --- a/tests/Inspector/Stdio/snapshots/StdioCustomDependenciesTest-prompts_list.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "prompts": [] -} diff --git a/tests/Inspector/Stdio/snapshots/StdioCustomDependenciesTest-resources_list.json b/tests/Inspector/Stdio/snapshots/StdioCustomDependenciesTest-resources_list.json deleted file mode 100644 index 0b80ce0f..00000000 --- a/tests/Inspector/Stdio/snapshots/StdioCustomDependenciesTest-resources_list.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "resources": [ - { - "name": "system_stats", - "uri": "stats://system/overview", - "description": "Provides current system statistics.", - "mimeType": "application/json" - } - ] -} diff --git a/tests/Inspector/Stdio/snapshots/StdioCustomDependenciesTest-resources_read-read_system_stats.json b/tests/Inspector/Stdio/snapshots/StdioCustomDependenciesTest-resources_read-read_system_stats.json deleted file mode 100644 index bdea849a..00000000 --- a/tests/Inspector/Stdio/snapshots/StdioCustomDependenciesTest-resources_read-read_system_stats.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "contents": [ - { - "uri": "stats://system/overview", - "mimeType": "application/json", - "text": "{\n \"total_tasks\": 3,\n \"completed_tasks\": 0,\n \"pending_tasks\": 3,\n \"server_uptime_seconds\": 12345\n}" - } - ] -} diff --git a/tests/Inspector/Stdio/snapshots/StdioCustomDependenciesTest-resources_templates_list.json b/tests/Inspector/Stdio/snapshots/StdioCustomDependenciesTest-resources_templates_list.json deleted file mode 100644 index e867d9d2..00000000 --- a/tests/Inspector/Stdio/snapshots/StdioCustomDependenciesTest-resources_templates_list.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "resourceTemplates": [] -} diff --git a/tests/Inspector/Stdio/snapshots/StdioCustomDependenciesTest-tools_call-add_task.json b/tests/Inspector/Stdio/snapshots/StdioCustomDependenciesTest-tools_call-add_task.json deleted file mode 100644 index 193d8b8e..00000000 --- a/tests/Inspector/Stdio/snapshots/StdioCustomDependenciesTest-tools_call-add_task.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "content": [ - { - "type": "text", - "text": "{\n \"id\": 4,\n \"userId\": \"alice\",\n \"description\": \"Complete the project documentation\",\n \"completed\": false,\n \"createdAt\": \"2025-01-01T00:00:00+00:00\"\n}" - } - ], - "structuredContent": { - "id": 4, - "userId": "alice", - "description": "Complete the project documentation", - "completed": false, - "createdAt": "2025-01-01T00:00:00+00:00" - }, - "isError": false -} \ No newline at end of file diff --git a/tests/Inspector/Stdio/snapshots/StdioCustomDependenciesTest-tools_call-complete_task.json b/tests/Inspector/Stdio/snapshots/StdioCustomDependenciesTest-tools_call-complete_task.json deleted file mode 100644 index 3e17038b..00000000 --- a/tests/Inspector/Stdio/snapshots/StdioCustomDependenciesTest-tools_call-complete_task.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "content": [ - { - "type": "text", - "text": "{\n \"success\": true,\n \"message\": \"Task 1 completed.\"\n}" - } - ], - "structuredContent": { - "success": true, - "message": "Task 1 completed." - }, - "isError": false -} diff --git a/tests/Inspector/Stdio/snapshots/StdioCustomDependenciesTest-tools_call-list_user_tasks.json b/tests/Inspector/Stdio/snapshots/StdioCustomDependenciesTest-tools_call-list_user_tasks.json deleted file mode 100644 index 6fac3026..00000000 --- a/tests/Inspector/Stdio/snapshots/StdioCustomDependenciesTest-tools_call-list_user_tasks.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "content": [ - { - "type": "text", - "text": "[]" - } - ], - "isError": false -} diff --git a/tests/Inspector/Stdio/snapshots/StdioCustomDependenciesTest-tools_list.json b/tests/Inspector/Stdio/snapshots/StdioCustomDependenciesTest-tools_list.json deleted file mode 100644 index 247b27fc..00000000 --- a/tests/Inspector/Stdio/snapshots/StdioCustomDependenciesTest-tools_list.json +++ /dev/null @@ -1,57 +0,0 @@ -{ - "tools": [ - { - "name": "add_task", - "description": "Adds a new task for a given user.", - "inputSchema": { - "type": "object", - "properties": { - "userId": { - "type": "string", - "description": "the ID of the user" - }, - "description": { - "type": "string", - "description": "the task description" - } - }, - "required": [ - "userId", - "description" - ] - } - }, - { - "name": "list_user_tasks", - "description": "Lists pending tasks for a specific user.", - "inputSchema": { - "type": "object", - "properties": { - "userId": { - "type": "string", - "description": "the ID of the user" - } - }, - "required": [ - "userId" - ] - } - }, - { - "name": "complete_task", - "description": "Marks a task as complete.", - "inputSchema": { - "type": "object", - "properties": { - "taskId": { - "type": "integer", - "description": "the ID of the task to complete" - } - }, - "required": [ - "taskId" - ] - } - } - ] -} diff --git a/tests/Inspector/Stdio/snapshots/StdioDiscoveryCalculatorTest-prompts_list.json b/tests/Inspector/Stdio/snapshots/StdioDiscoveryCalculatorTest-prompts_list.json deleted file mode 100644 index 7292222c..00000000 --- a/tests/Inspector/Stdio/snapshots/StdioDiscoveryCalculatorTest-prompts_list.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "prompts": [] -} diff --git a/tests/Inspector/Stdio/snapshots/StdioDiscoveryCalculatorTest-resources_list.json b/tests/Inspector/Stdio/snapshots/StdioDiscoveryCalculatorTest-resources_list.json deleted file mode 100644 index e72548b5..00000000 --- a/tests/Inspector/Stdio/snapshots/StdioDiscoveryCalculatorTest-resources_list.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "resources": [ - { - "name": "calculator_config", - "uri": "config://calculator/settings", - "description": "Current settings for the calculator tool (precision, allow_negative).", - "icons": [ - { - "mimeType": "image/svg+xml", - "sizes": [ - "any" - ], - "src": "https://www.svgrepo.com/show/529867/settings.svg" - } - ], - "mimeType": "application/json" - } - ] -} diff --git a/tests/Inspector/Stdio/snapshots/StdioDiscoveryCalculatorTest-resources_read-read_config.json b/tests/Inspector/Stdio/snapshots/StdioDiscoveryCalculatorTest-resources_read-read_config.json deleted file mode 100644 index c15d9a8e..00000000 --- a/tests/Inspector/Stdio/snapshots/StdioDiscoveryCalculatorTest-resources_read-read_config.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "contents": [ - { - "uri": "config://calculator/settings", - "mimeType": "application/json", - "text": "{\n \"precision\": 2,\n \"allow_negative\": true\n}" - } - ] -} diff --git a/tests/Inspector/Stdio/snapshots/StdioDiscoveryCalculatorTest-resources_read.json b/tests/Inspector/Stdio/snapshots/StdioDiscoveryCalculatorTest-resources_read.json deleted file mode 100644 index c15d9a8e..00000000 --- a/tests/Inspector/Stdio/snapshots/StdioDiscoveryCalculatorTest-resources_read.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "contents": [ - { - "uri": "config://calculator/settings", - "mimeType": "application/json", - "text": "{\n \"precision\": 2,\n \"allow_negative\": true\n}" - } - ] -} diff --git a/tests/Inspector/Stdio/snapshots/StdioDiscoveryCalculatorTest-resources_templates_list.json b/tests/Inspector/Stdio/snapshots/StdioDiscoveryCalculatorTest-resources_templates_list.json deleted file mode 100644 index e867d9d2..00000000 --- a/tests/Inspector/Stdio/snapshots/StdioDiscoveryCalculatorTest-resources_templates_list.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "resourceTemplates": [] -} diff --git a/tests/Inspector/Stdio/snapshots/StdioDiscoveryCalculatorTest-tools_call-calculate_sum.json b/tests/Inspector/Stdio/snapshots/StdioDiscoveryCalculatorTest-tools_call-calculate_sum.json deleted file mode 100644 index a73c8b94..00000000 --- a/tests/Inspector/Stdio/snapshots/StdioDiscoveryCalculatorTest-tools_call-calculate_sum.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "content": [ - { - "type": "text", - "text": "19.8" - } - ], - "isError": false -} diff --git a/tests/Inspector/Stdio/snapshots/StdioDiscoveryCalculatorTest-tools_call-update_setting.json b/tests/Inspector/Stdio/snapshots/StdioDiscoveryCalculatorTest-tools_call-update_setting.json deleted file mode 100644 index 51786523..00000000 --- a/tests/Inspector/Stdio/snapshots/StdioDiscoveryCalculatorTest-tools_call-update_setting.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "content": [ - { - "type": "text", - "text": "{\n \"success\": true,\n \"message\": \"Precision updated to 3.\"\n}" - } - ], - "structuredContent": { - "success": true, - "message": "Precision updated to 3." - }, - "isError": false -} diff --git a/tests/Inspector/Stdio/snapshots/StdioDiscoveryCalculatorTest-tools_call.json b/tests/Inspector/Stdio/snapshots/StdioDiscoveryCalculatorTest-tools_call.json deleted file mode 100644 index bdfec0c0..00000000 --- a/tests/Inspector/Stdio/snapshots/StdioDiscoveryCalculatorTest-tools_call.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "content": [ - { - "type": "text", - "text": "19.8" - } - ], - "isError": false, - "structuredContent": { - "result": 19.8 - } -} diff --git a/tests/Inspector/Stdio/snapshots/StdioDiscoveryCalculatorTest-tools_list.json b/tests/Inspector/Stdio/snapshots/StdioDiscoveryCalculatorTest-tools_list.json deleted file mode 100644 index 8ada3477..00000000 --- a/tests/Inspector/Stdio/snapshots/StdioDiscoveryCalculatorTest-tools_list.json +++ /dev/null @@ -1,59 +0,0 @@ -{ - "tools": [ - { - "name": "calculate", - "description": "Performs a calculation based on the operation.\n\nSupports 'add', 'subtract', 'multiply', 'divide'.\nObeys the 'precision' and 'allow_negative' settings from the config resource.", - "icons": [ - { - "mimeType": "image/svg+xml", - "sizes": [ - "any" - ], - "src": "https://www.svgrepo.com/show/530644/calculator.svg" - } - ], - "inputSchema": { - "type": "object", - "properties": { - "a": { - "type": "number", - "description": "the first operand" - }, - "b": { - "type": "number", - "description": "the second operand" - }, - "operation": { - "type": "string", - "description": "the operation ('add', 'subtract', 'multiply', 'divide')" - } - }, - "required": [ - "a", - "b", - "operation" - ] - } - }, - { - "name": "update_setting", - "description": "Updates a specific configuration setting.\n\nNote: This requires more robust validation in a real app.", - "inputSchema": { - "type": "object", - "properties": { - "setting": { - "type": "string", - "description": "the setting key ('precision' or 'allow_negative')" - }, - "value": { - "description": "the new value (int for precision, bool for allow_negative)" - } - }, - "required": [ - "setting", - "value" - ] - } - } - ] -} diff --git a/tests/Inspector/Stdio/snapshots/StdioEnvVariablesTest-prompts_list.json b/tests/Inspector/Stdio/snapshots/StdioEnvVariablesTest-prompts_list.json deleted file mode 100644 index 7292222c..00000000 --- a/tests/Inspector/Stdio/snapshots/StdioEnvVariablesTest-prompts_list.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "prompts": [] -} diff --git a/tests/Inspector/Stdio/snapshots/StdioEnvVariablesTest-resources_list.json b/tests/Inspector/Stdio/snapshots/StdioEnvVariablesTest-resources_list.json deleted file mode 100644 index d02ef58d..00000000 --- a/tests/Inspector/Stdio/snapshots/StdioEnvVariablesTest-resources_list.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "resources": [] -} diff --git a/tests/Inspector/Stdio/snapshots/StdioEnvVariablesTest-resources_templates_list.json b/tests/Inspector/Stdio/snapshots/StdioEnvVariablesTest-resources_templates_list.json deleted file mode 100644 index e867d9d2..00000000 --- a/tests/Inspector/Stdio/snapshots/StdioEnvVariablesTest-resources_templates_list.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "resourceTemplates": [] -} diff --git a/tests/Inspector/Stdio/snapshots/StdioEnvVariablesTest-tools_call-process_data_debug.json b/tests/Inspector/Stdio/snapshots/StdioEnvVariablesTest-tools_call-process_data_debug.json deleted file mode 100644 index b046832e..00000000 --- a/tests/Inspector/Stdio/snapshots/StdioEnvVariablesTest-tools_call-process_data_debug.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "content": [ - { - "type": "text", - "text": "{\n \"mode\": \"debug\",\n \"processed_input\": \"DEBUG TEST\",\n \"message\": \"Processed in DEBUG mode.\"\n}" - } - ], - "isError": false, - "structuredContent": { - "mode": "debug", - "processed_input": "DEBUG TEST", - "message": "Processed in DEBUG mode." - } -} diff --git a/tests/Inspector/Stdio/snapshots/StdioEnvVariablesTest-tools_call-process_data_default.json b/tests/Inspector/Stdio/snapshots/StdioEnvVariablesTest-tools_call-process_data_default.json deleted file mode 100644 index af00a82b..00000000 --- a/tests/Inspector/Stdio/snapshots/StdioEnvVariablesTest-tools_call-process_data_default.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "content": [ - { - "type": "text", - "text": "{\n \"mode\": \"default\",\n \"original_input\": \"test data\",\n \"message\": \"Processed in default mode (APP_MODE not recognized or not set).\"\n}" - } - ], - "isError": false, - "structuredContent": { - "mode": "default", - "original_input": "test data", - "message": "Processed in default mode (APP_MODE not recognized or not set)." - } -} diff --git a/tests/Inspector/Stdio/snapshots/StdioEnvVariablesTest-tools_call-process_data_production.json b/tests/Inspector/Stdio/snapshots/StdioEnvVariablesTest-tools_call-process_data_production.json deleted file mode 100644 index 4f30f8a0..00000000 --- a/tests/Inspector/Stdio/snapshots/StdioEnvVariablesTest-tools_call-process_data_production.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "content": [ - { - "type": "text", - "text": "{\n \"mode\": \"production\",\n \"processed_input_length\": 15,\n \"message\": \"Processed in PRODUCTION mode (summary only).\"\n}" - } - ], - "isError": false, - "structuredContent": { - "mode": "production", - "processed_input_length": 15, - "message": "Processed in PRODUCTION mode (summary only)." - } -} diff --git a/tests/Inspector/Stdio/snapshots/StdioEnvVariablesTest-tools_list.json b/tests/Inspector/Stdio/snapshots/StdioEnvVariablesTest-tools_list.json deleted file mode 100644 index cfa18f09..00000000 --- a/tests/Inspector/Stdio/snapshots/StdioEnvVariablesTest-tools_list.json +++ /dev/null @@ -1,45 +0,0 @@ -{ - "tools": [ - { - "name": "process_data_by_mode", - "description": "Performs an action that can be modified by an environment variable.\n\nThe MCP client should set 'APP_MODE' in its 'env' config for this server.", - "inputSchema": { - "type": "object", - "properties": { - "input": { - "type": "string", - "description": "some input data" - } - }, - "required": [ - "input" - ] - }, - "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" - ] - } - } - ] -} diff --git a/tests/Inspector/Stdio/snapshots/StdioExplicitRegistrationTest-prompts_get-personalized_greeting_alice.json b/tests/Inspector/Stdio/snapshots/StdioExplicitRegistrationTest-prompts_get-personalized_greeting_alice.json deleted file mode 100644 index b5777fa4..00000000 --- a/tests/Inspector/Stdio/snapshots/StdioExplicitRegistrationTest-prompts_get-personalized_greeting_alice.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "messages": [ - { - "role": "user", - "content": { - "type": "text", - "text": "Craft a personalized greeting for Alice." - } - } - ] -} diff --git a/tests/Inspector/Stdio/snapshots/StdioExplicitRegistrationTest-prompts_get-personalized_greeting_bob.json b/tests/Inspector/Stdio/snapshots/StdioExplicitRegistrationTest-prompts_get-personalized_greeting_bob.json deleted file mode 100644 index e432a6f4..00000000 --- a/tests/Inspector/Stdio/snapshots/StdioExplicitRegistrationTest-prompts_get-personalized_greeting_bob.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "messages": [ - { - "role": "user", - "content": { - "type": "text", - "text": "Craft a personalized greeting for Bob." - } - } - ] -} diff --git a/tests/Inspector/Stdio/snapshots/StdioExplicitRegistrationTest-prompts_list.json b/tests/Inspector/Stdio/snapshots/StdioExplicitRegistrationTest-prompts_list.json deleted file mode 100644 index ea0beb35..00000000 --- a/tests/Inspector/Stdio/snapshots/StdioExplicitRegistrationTest-prompts_list.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "prompts": [ - { - "name": "personalized_greeting", - "description": "A manually registered prompt template.", - "arguments": [ - { - "name": "userName", - "description": "the name of the user", - "required": true - } - ] - } - ] -} diff --git a/tests/Inspector/Stdio/snapshots/StdioExplicitRegistrationTest-resources_list.json b/tests/Inspector/Stdio/snapshots/StdioExplicitRegistrationTest-resources_list.json deleted file mode 100644 index 3faa772c..00000000 --- a/tests/Inspector/Stdio/snapshots/StdioExplicitRegistrationTest-resources_list.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "resources": [ - { - "name": "application_version", - "uri": "app://version", - "description": "A manually registered resource providing app version.", - "mimeType": "text/plain" - } - ] -} diff --git a/tests/Inspector/Stdio/snapshots/StdioExplicitRegistrationTest-resources_read-read_app_version.json b/tests/Inspector/Stdio/snapshots/StdioExplicitRegistrationTest-resources_read-read_app_version.json deleted file mode 100644 index 1547bdaa..00000000 --- a/tests/Inspector/Stdio/snapshots/StdioExplicitRegistrationTest-resources_read-read_app_version.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "contents": [ - { - "uri": "app://version", - "mimeType": "text/plain", - "text": "1.0-manual" - } - ] -} diff --git a/tests/Inspector/Stdio/snapshots/StdioExplicitRegistrationTest-resources_read-read_item_123_details.json b/tests/Inspector/Stdio/snapshots/StdioExplicitRegistrationTest-resources_read-read_item_123_details.json deleted file mode 100644 index d3eca519..00000000 --- a/tests/Inspector/Stdio/snapshots/StdioExplicitRegistrationTest-resources_read-read_item_123_details.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "contents": [ - { - "uri": "item://123/details", - "mimeType": "application/json", - "text": "{\n \"id\": \"123\",\n \"name\": \"Item 123\",\n \"description\": \"Details for item 123 from manual template.\"\n}" - } - ] -} diff --git a/tests/Inspector/Stdio/snapshots/StdioExplicitRegistrationTest-resources_read-read_item_ABC_details.json b/tests/Inspector/Stdio/snapshots/StdioExplicitRegistrationTest-resources_read-read_item_ABC_details.json deleted file mode 100644 index 6a2dd65f..00000000 --- a/tests/Inspector/Stdio/snapshots/StdioExplicitRegistrationTest-resources_read-read_item_ABC_details.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "contents": [ - { - "uri": "item://ABC/details", - "mimeType": "application/json", - "text": "{\n \"id\": \"ABC\",\n \"name\": \"Item ABC\",\n \"description\": \"Details for item ABC from manual template.\"\n}" - } - ] -} diff --git a/tests/Inspector/Stdio/snapshots/StdioExplicitRegistrationTest-resources_templates_list.json b/tests/Inspector/Stdio/snapshots/StdioExplicitRegistrationTest-resources_templates_list.json deleted file mode 100644 index 65883802..00000000 --- a/tests/Inspector/Stdio/snapshots/StdioExplicitRegistrationTest-resources_templates_list.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "resourceTemplates": [ - { - "name": "get_item_details", - "uriTemplate": "item://{itemId}/details", - "description": "A manually registered resource template.", - "mimeType": "application/json" - } - ] -} diff --git a/tests/Inspector/Stdio/snapshots/StdioExplicitRegistrationTest-tools_call-echo_text.json b/tests/Inspector/Stdio/snapshots/StdioExplicitRegistrationTest-tools_call-echo_text.json deleted file mode 100644 index 9bad1e77..00000000 --- a/tests/Inspector/Stdio/snapshots/StdioExplicitRegistrationTest-tools_call-echo_text.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "content": [ - { - "type": "text", - "text": "Echo: Hello World!" - } - ], - "isError": false -} diff --git a/tests/Inspector/Stdio/snapshots/StdioExplicitRegistrationTest-tools_call-echo_text_special_chars.json b/tests/Inspector/Stdio/snapshots/StdioExplicitRegistrationTest-tools_call-echo_text_special_chars.json deleted file mode 100644 index bf4fc3c7..00000000 --- a/tests/Inspector/Stdio/snapshots/StdioExplicitRegistrationTest-tools_call-echo_text_special_chars.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "content": [ - { - "type": "text", - "text": "Echo: Test with emoji 🎉 and symbols @#$%" - } - ], - "isError": false -} diff --git a/tests/Inspector/Stdio/snapshots/StdioExplicitRegistrationTest-tools_list.json b/tests/Inspector/Stdio/snapshots/StdioExplicitRegistrationTest-tools_list.json deleted file mode 100644 index a5ecc534..00000000 --- a/tests/Inspector/Stdio/snapshots/StdioExplicitRegistrationTest-tools_list.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "tools": [ - { - "name": "echo_text", - "description": "A manually registered tool to echo input.", - "inputSchema": { - "type": "object", - "properties": { - "text": { - "type": "string", - "description": "the text to echo" - } - }, - "required": [ - "text" - ] - } - } - ] -} diff --git a/tests/Inspector/Stdio/snapshots/StdioMcpAppsTest-prompts_list.json b/tests/Inspector/Stdio/snapshots/StdioMcpAppsTest-prompts_list.json deleted file mode 100644 index 7292222c..00000000 --- a/tests/Inspector/Stdio/snapshots/StdioMcpAppsTest-prompts_list.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "prompts": [] -} diff --git a/tests/Inspector/Stdio/snapshots/StdioMcpAppsTest-resources_list.json b/tests/Inspector/Stdio/snapshots/StdioMcpAppsTest-resources_list.json deleted file mode 100644 index 2ff24ba4..00000000 --- a/tests/Inspector/Stdio/snapshots/StdioMcpAppsTest-resources_list.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "resources": [ - { - "name": "weather-app", - "uri": "ui://weather-app", - "description": "Interactive weather dashboard", - "mimeType": "text/html;profile=mcp-app", - "_meta": { - "ui": {} - } - } - ] -} diff --git a/tests/Inspector/Stdio/snapshots/StdioMcpAppsTest-resources_read-read_weather_ui.json b/tests/Inspector/Stdio/snapshots/StdioMcpAppsTest-resources_read-read_weather_ui.json deleted file mode 100644 index 84e17515..00000000 --- a/tests/Inspector/Stdio/snapshots/StdioMcpAppsTest-resources_read-read_weather_ui.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "contents": [ - { - "uri": "ui://weather-app", - "mimeType": "text/html;profile=mcp-app", - "_meta": { - "ui": { - "csp": { - "connectDomains": [ - "https://api.weather.example.com" - ] - }, - "permissions": { - "geolocation": {} - }, - "prefersBorder": true - } - }, - "text": "\n\n\n \n \n Weather Dashboard\n \n\n\n
\n \n \n
\n
\n
\n
\n
\n
\n
\n
🌤️
\n
\n
\n
\n Humidity —\n
\n
\n
\n \n\n\n" - } - ] -} diff --git a/tests/Inspector/Stdio/snapshots/StdioMcpAppsTest-resources_templates_list.json b/tests/Inspector/Stdio/snapshots/StdioMcpAppsTest-resources_templates_list.json deleted file mode 100644 index e867d9d2..00000000 --- a/tests/Inspector/Stdio/snapshots/StdioMcpAppsTest-resources_templates_list.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "resourceTemplates": [] -} diff --git a/tests/Inspector/Stdio/snapshots/StdioMcpAppsTest-tools_call-get_weather_london.json b/tests/Inspector/Stdio/snapshots/StdioMcpAppsTest-tools_call-get_weather_london.json deleted file mode 100644 index 55cac881..00000000 --- a/tests/Inspector/Stdio/snapshots/StdioMcpAppsTest-tools_call-get_weather_london.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "content": [ - { - "type": "text", - "text": "Weather in London: 15°C, Cloudy, Humidity: 78%" - } - ], - "isError": false -} diff --git a/tests/Inspector/Stdio/snapshots/StdioMcpAppsTest-tools_call-get_weather_tokyo.json b/tests/Inspector/Stdio/snapshots/StdioMcpAppsTest-tools_call-get_weather_tokyo.json deleted file mode 100644 index a79f5014..00000000 --- a/tests/Inspector/Stdio/snapshots/StdioMcpAppsTest-tools_call-get_weather_tokyo.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "content": [ - { - "type": "text", - "text": "Weather in Tokyo: 22°C, Partly Cloudy, Humidity: 65%" - } - ], - "isError": false -} diff --git a/tests/Inspector/Stdio/snapshots/StdioMcpAppsTest-tools_list.json b/tests/Inspector/Stdio/snapshots/StdioMcpAppsTest-tools_list.json deleted file mode 100644 index dcb064ab..00000000 --- a/tests/Inspector/Stdio/snapshots/StdioMcpAppsTest-tools_list.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "tools": [ - { - "name": "get_weather", - "description": "Get current weather for a city", - "inputSchema": { - "type": "object", - "properties": { - "city": { - "type": "string" - } - }, - "required": [ - "city" - ] - }, - "_meta": { - "ui": { - "resourceUri": "ui://weather-app", - "visibility": [ - "model", - "app" - ] - } - } - } - ] -} diff --git a/tests/Integration/ElicitationTest.php b/tests/Integration/ElicitationTest.php deleted file mode 100644 index dc168479..00000000 --- a/tests/Integration/ElicitationTest.php +++ /dev/null @@ -1,107 +0,0 @@ -connect('elicitation', $this->clientAnswering( - new ElicitResult(ElicitAction::Accept, ['name' => 'Ada']), - )); - - $result = $client->callTool('ask_name'); - - $this->assertFalse($result->isError); - $this->assertInstanceOf(TextContent::class, $result->content[0]); - $this->assertSame('accept:Ada', $result->content[0]->text); - } - - #[TestDox('a declined elicitation reaches the tool as a decline, not an error')] - public function testDeclinedElicitation(): void - { - $client = $this->connect('elicitation', $this->clientAnswering( - new ElicitResult(ElicitAction::Decline), - )); - - $result = $client->callTool('ask_name'); - - $this->assertInstanceOf(TextContent::class, $result->content[0]); - $this->assertSame('decline:', $result->content[0]->text); - } - - #[TestDox('a client that does not advertise elicitation is not asked')] - public function testCapabilityIsVisibleToTheServer(): void - { - // The tool consults supportsElicitation(), which answers from the - // capabilities this client sent during the handshake. - $client = $this->connect('elicitation'); - - $result = $client->callTool('ask_name'); - - $this->assertInstanceOf(TextContent::class, $result->content[0]); - $this->assertSame('unsupported', $result->content[0]->text); - } - - #[TestDox('a client advertising elicitation without a handler fails the tool call')] - public function testAdvertisedCapabilityWithoutHandler(): void - { - // The client answers "method not found", which the gateway raises inside - // the tool as a ClientException rather than leaving it waiting. - $client = $this->connect( - 'elicitation', - $this->clientBuilder()->setCapabilities(new ClientCapabilities(elicitation: true)), - ); - - $result = $client->callTool('ask_name'); - - $this->assertInstanceOf(TextContent::class, $result->content[0]); - $this->assertSame('Client does not handle "elicitation/create" requests.', $result->content[0]->text); - } - - private function clientAnswering(ElicitResult $answer): ClientBuilder - { - $callback = new class($answer) implements ElicitationCallbackInterface { - public function __construct(private readonly ElicitResult $answer) - { - } - - public function __invoke(ElicitRequest $request): ElicitResult - { - return $this->answer; - } - }; - - return $this->clientBuilder() - ->setCapabilities(new ClientCapabilities(elicitation: true)) - ->addRequestHandler(new ElicitationRequestHandler($callback)); - } -} diff --git a/tests/Integration/Fixture/elicitation.php b/tests/Integration/Fixture/elicitation.php deleted file mode 100644 index ad588a94..00000000 --- a/tests/Integration/Fixture/elicitation.php +++ /dev/null @@ -1,49 +0,0 @@ -setServerInfo('integration-server', '1.0.0') - ->addTool( - static function (RequestContext $context): string { - $gateway = $context->getClientGateway(); - - if (!$gateway->supportsElicitation()) { - return 'unsupported'; - } - - try { - $result = $gateway->elicit('What is your name?', new ElicitationSchema([ - 'name' => new StringSchemaDefinition(title: 'Name'), - ])); - } catch (ClientException $e) { - return $e->getMessage(); - } - - return sprintf('%s:%s', $result->action->value, $result->content['name'] ?? ''); - }, - name: 'ask_name', - description: 'Asks the client for a name.', - ) - ->build() - ->run(new StdioTransport()); diff --git a/tests/Integration/Fixture/handshake.php b/tests/Integration/Fixture/handshake.php deleted file mode 100644 index 776f4513..00000000 --- a/tests/Integration/Fixture/handshake.php +++ /dev/null @@ -1,32 +0,0 @@ -setServerInfo('integration-server', '1.0.0') - ->setInstructions('Be brief.'); - -if (is_string($pinned = getenv('MCP_INTEGRATION_PROTOCOL_VERSION')) && '' !== $pinned) { - $builder->setProtocolVersion(ProtocolVersion::from($pinned)); -} - -$builder->build()->run(new StdioTransport()); diff --git a/tests/Integration/Fixture/notification.php b/tests/Integration/Fixture/notification.php deleted file mode 100644 index 166e84c6..00000000 --- a/tests/Integration/Fixture/notification.php +++ /dev/null @@ -1,39 +0,0 @@ -setServerInfo('integration-server', '1.0.0') - ->addTool( - static function (RequestContext $context): string { - $gateway = $context->getClientGateway(); - - $gateway->log(LoggingLevel::Info, 'starting work'); - $gateway->progress(0.5, 1.0, 'halfway'); - $gateway->progress(1.0, 1.0, 'done'); - - return 'finished'; - }, - name: 'work', - description: 'Reports progress and logs while working.', - ) - ->build() - ->run(new StdioTransport()); diff --git a/tests/Integration/Fixture/request_timeout.php b/tests/Integration/Fixture/request_timeout.php deleted file mode 100644 index fb3697c6..00000000 --- a/tests/Integration/Fixture/request_timeout.php +++ /dev/null @@ -1,42 +0,0 @@ -setServerInfo('integration-server', '1.0.0') - ->addTool( - static function (): string { - sleep(2); - - return 'late'; - }, - name: 'slow', - description: 'Answers well after the client stopped waiting.', - ) - ->addTool( - static fn (): string => 'quick', - name: 'fast', - description: 'Returns at once.', - ) - ->build() - ->run(new StdioTransport()); diff --git a/tests/Integration/Fixture/retry.php b/tests/Integration/Fixture/retry.php deleted file mode 100644 index 9bd25ee3..00000000 --- a/tests/Integration/Fixture/retry.php +++ /dev/null @@ -1,46 +0,0 @@ -setServerInfo('integration-server', '1.0.0') - ->addTool( - static fn (): string => 'quick', - name: 'fast', - description: 'Returns at once.', - ) - ->build() - ->run(new StdioTransport()); diff --git a/tests/Integration/Fixture/roots.php b/tests/Integration/Fixture/roots.php deleted file mode 100644 index a4b63824..00000000 --- a/tests/Integration/Fixture/roots.php +++ /dev/null @@ -1,43 +0,0 @@ -setServerInfo('integration-server', '1.0.0') - ->addTool( - static function (RequestContext $context): string { - $gateway = $context->getClientGateway(); - - if (!$gateway->supportsRoots()) { - return 'unsupported'; - } - - $described = []; - foreach ($gateway->listRoots()->roots as $root) { - $described[] = sprintf('%s (%s)', $root->uri, $root->name ?? '-'); - } - - return implode(', ', $described); - }, - name: 'inspect_roots', - description: 'Reports the workspace roots the client exposes.', - ) - ->build() - ->run(new StdioTransport()); diff --git a/tests/Integration/Fixture/sampling.php b/tests/Integration/Fixture/sampling.php deleted file mode 100644 index bb37bbfa..00000000 --- a/tests/Integration/Fixture/sampling.php +++ /dev/null @@ -1,42 +0,0 @@ -setServerInfo('integration-server', '1.0.0') - ->addTool( - static function (RequestContext $context, string $text): string { - try { - $result = $context->getClientGateway()->sample($text, maxTokens: 64); - } catch (ClientException $e) { - return $e->getMessage(); - } - - assert($result->content instanceof TextContent); - - return sprintf('%s said: %s', $result->model, $result->content->text); - }, - name: 'summarize', - description: 'Summarizes text by asking the client to sample.', - ) - ->build() - ->run(new StdioTransport()); diff --git a/tests/Integration/Fixture/sampling_tools.php b/tests/Integration/Fixture/sampling_tools.php deleted file mode 100644 index 94cd5612..00000000 --- a/tests/Integration/Fixture/sampling_tools.php +++ /dev/null @@ -1,78 +0,0 @@ - 'object', 'properties' => ['city' => ['type' => 'string']], 'required' => ['city']], - 'Get current weather for a city', - null, -); - -Server::builder() - ->setServerInfo('integration-server', '1.0.0') - ->addTool( - static function (RequestContext $context, string $city) use ($weather): string { - $gateway = $context->getClientGateway(); - - // The spec forbids sending tools to a client that did not advertise - // sampling.tools, so the loop is only entered when it did. - if (!$gateway->supportsSamplingTools()) { - return 'client cannot use tools during sampling'; - } - - $messages = [new SamplingMessage(Role::User, new TextContent(sprintf('Weather in %s?', $city)))]; - - $answer = $gateway->sample($messages, maxTokens: 64, options: ['tools' => [$weather]]); - $messages[] = new SamplingMessage(Role::Assistant, $answer->content); - - $toolResults = []; - foreach ($answer->getContentBlocks() as $block) { - if ($block instanceof ToolUseContent) { - $toolResults[] = new ToolResultContent( - $block->id, - [new TextContent(sprintf('%s: 18 C', $block->input['city'] ?? 'unknown'))], - ); - } - } - - if ([] === $toolResults) { - return 'the model asked for no tools'; - } - - $messages[] = new SamplingMessage(Role::User, $toolResults); - - $final = $gateway->sample($messages, maxTokens: 64, options: ['tools' => [$weather]]); - assert($final->content instanceof TextContent); - - return sprintf('%s (%s)', $final->content->text, $final->stopReason); - }, - name: 'weather_report', - description: 'Reports weather by running a sampling tool loop.', - ) - ->build() - ->run(new StdioTransport()); diff --git a/tests/Integration/HandshakeTest.php b/tests/Integration/HandshakeTest.php deleted file mode 100644 index 5e0455ff..00000000 --- a/tests/Integration/HandshakeTest.php +++ /dev/null @@ -1,85 +0,0 @@ -clientBuilder(); - if (null !== $clientVersion) { - $client->setProtocolVersion($clientVersion); - } - - $connected = $this->connect( - 'handshake', - $client, - null !== $serverVersion ? ['MCP_INTEGRATION_PROTOCOL_VERSION' => $serverVersion->value] : [], - ); - - $this->assertSame($expected, $connected->getProtocolVersion()); - } - - /** - * @return iterable - */ - public static function provideNegotiations(): iterable - { - $latest = ProtocolVersion::latestHandshake(); - - yield 'both unconfigured' => [null, null, $latest]; - - // Whichever end of the supported range it sits at. - foreach (ProtocolVersion::handshakeVersions() as $version) { - yield \sprintf('client asks for %s', $version->value) => [$version, null, $version]; - } - - // A pinned server answers with its pin, and the client continues on it. - yield 'server pins an older revision' => [ProtocolVersion::V2025_11_25, ProtocolVersion::V2025_03_26, ProtocolVersion::V2025_03_26]; - yield 'server pins a newer revision' => [ProtocolVersion::V2024_11_05, ProtocolVersion::V2025_11_25, ProtocolVersion::V2025_11_25]; - yield 'both pin the same revision' => [ProtocolVersion::V2025_06_18, ProtocolVersion::V2025_06_18, ProtocolVersion::V2025_06_18]; - - // Neither side reaches the modern era through `initialize`, so - // configuring it falls back to the handshake set on both ends. - yield 'client configured modern' => [ProtocolVersion::V2026_07_28, null, $latest]; - yield 'server configured modern' => [ProtocolVersion::V2025_06_18, ProtocolVersion::V2026_07_28, ProtocolVersion::V2025_06_18]; - yield 'both configured modern' => [ProtocolVersion::V2026_07_28, ProtocolVersion::V2026_07_28, $latest]; - } - - #[TestDox('the handshake carries the server identity to the client')] - public function testServerInfoIsExchanged(): void - { - $client = $this->connect('handshake'); - - $this->assertSame('integration-server', $client->getServerInfo()->name); - $this->assertSame('1.0.0', $client->getServerInfo()->version); - $this->assertSame('Be brief.', $client->getInstructions()); - $this->assertTrue($client->isConnected()); - } - - #[TestDox('the negotiated revision is unset before the handshake')] - public function testProtocolVersionIsNullBeforeConnecting(): void - { - $this->assertNull($this->clientBuilder()->build()->getProtocolVersion()); - } -} diff --git a/tests/Integration/IntegrationTestCase.php b/tests/Integration/IntegrationTestCase.php deleted file mode 100644 index 4ad33d42..00000000 --- a/tests/Integration/IntegrationTestCase.php +++ /dev/null @@ -1,99 +0,0 @@ - - */ -abstract class IntegrationTestCase extends TestCase -{ - /** - * Both sides answer immediately, so anything reaching this is a deadlock. - * Far below the SDK's two-minute default, to fail rather than hang. - */ - private const TIMEOUT = 5; - - private ?Client $client = null; - - protected function clientBuilder(): ClientBuilder - { - return Client::builder() - ->setClientInfo('integration-client', '1.0.0') - ->setInitTimeout(self::TIMEOUT) - ->setRequestTimeout(self::TIMEOUT); - } - - /** - * Spawn a fixture server and connect a client to it. - * - * The returned client has completed the handshake. - * - * @param string $fixture basename of a script in {@see Fixture} - * @param array $env added to the server process environment - */ - protected function connect(string $fixture, ?ClientBuilder $client = null, array $env = []): Client - { - $this->client = ($client ?? $this->clientBuilder())->build(); - - try { - $this->client->connect($this->transport($fixture, $env)); - } catch (ConnectionException $e) { - // The transport discards the child's stderr, so a fixture dying on - // startup arrives here as a bare timeout. - $this->fail(\sprintf('Could not connect to fixture server "%s": %s. Run `%s %s` to see why.', $fixture, $e->getMessage(), \PHP_BINARY, self::script($fixture))); - } - - return $this->client; - } - - /** - * A transport that will spawn a fixture server, without connecting it. - * - * Tests that assert on a failing connection need the transport by itself, - * since {@see self::connect()} turns that failure into a test failure. - * - * @param array $env added to the server process environment - */ - protected function transport(string $fixture, array $env = []): StdioTransport - { - return new StdioTransport( - command: \PHP_BINARY, - args: [self::script($fixture)], - // proc_open() replaces the environment rather than adding to it. - env: [] === $env ? null : array_merge(getenv(), $env), - ); - } - - private static function script(string $fixture): string - { - return __DIR__.'/Fixture/'.$fixture.'.php'; - } - - protected function tearDown(): void - { - $this->client?->disconnect(); - $this->client = null; - } -} diff --git a/tests/Integration/NotificationTest.php b/tests/Integration/NotificationTest.php deleted file mode 100644 index 09487185..00000000 --- a/tests/Integration/NotificationTest.php +++ /dev/null @@ -1,75 +0,0 @@ -connect('notification'); - - $updates = []; - $result = $client->callTool('work', [], static function (float $progress, ?float $total, ?string $message) use (&$updates): void { - $updates[] = [$progress, $total, $message]; - }); - - $this->assertSame([[0.5, 1.0, 'halfway'], [1.0, 1.0, 'done']], $updates); - $this->assertInstanceOf(TextContent::class, $result->content[0]); - $this->assertSame('finished', $result->content[0]->text); - } - - #[TestDox('progress is skipped when the caller asked for none')] - public function testProgressIsSkippedWithoutAToken(): void - { - // Without an onProgress callback the request carries no progress token, - // so the gateway drops the notification instead of sending it. - $client = $this->connect('notification'); - - $result = $client->callTool('work'); - - $this->assertInstanceOf(TextContent::class, $result->content[0]); - $this->assertSame('finished', $result->content[0]->text); - } - - #[TestDox('log notifications reach a registered logging handler')] - public function testLoggingReachesTheClient(): void - { - $logged = []; - $client = $this->connect( - 'notification', - $this->clientBuilder()->addNotificationHandler(new LoggingNotificationHandler( - static function (LoggingMessageNotification $notification) use (&$logged): void { - $logged[] = [$notification->level, $notification->data]; - }, - )), - ); - - $client->callTool('work'); - - $this->assertSame([[LoggingLevel::Info, 'starting work']], $logged); - } -} diff --git a/tests/Integration/RequestTimeoutTest.php b/tests/Integration/RequestTimeoutTest.php deleted file mode 100644 index fa3ef4c4..00000000 --- a/tests/Integration/RequestTimeoutTest.php +++ /dev/null @@ -1,43 +0,0 @@ -connect('request_timeout', $this->clientBuilder()->setRequestTimeout(1)); - - try { - $client->callTool('slow'); - $this->fail('The slow tool should have outlived the request timeout.'); - } catch (RequestException) { - } - - // The server is still executing the abandoned call and cannot answer - // anything until it returns, so the next request has to wait it out. - sleep(2); - - // Without the pending request being cleared this answers 'late': the - // response the client gave up on, handed to the request after it. - $this->assertSame('quick', $client->callTool('fast')->content[0]->text ?? null); - } -} diff --git a/tests/Integration/RetryTest.php b/tests/Integration/RetryTest.php deleted file mode 100644 index 7fc974c7..00000000 --- a/tests/Integration/RetryTest.php +++ /dev/null @@ -1,93 +0,0 @@ -fail('Could not create the spawn counter file.'); - } - - $this->counter = $counter; - file_put_contents($this->counter, '0'); - } - - protected function tearDown(): void - { - parent::tearDown(); - - @unlink($this->counter); - } - - #[TestDox('a failed attempt is retried against a newly spawned server')] - public function testRetrySpawnsAnotherServer(): void - { - $client = $this->connect( - 'retry', - $this->clientBuilder()->setInitTimeout(2)->setMaxRetries(1), - $this->environment(failing: 1), - ); - - $this->assertSame(2, $this->spawns()); - $this->assertTrue($client->isConnected()); - $this->assertSame('quick', $client->callTool('fast')->content[0]->text ?? null); - } - - #[TestDox('no retries means the first failure is the last word')] - public function testRetriesCanBeDisabled(): void - { - $client = $this->clientBuilder()->setInitTimeout(2)->setMaxRetries(0)->build(); - - try { - $client->connect($this->transport('retry', $this->environment(failing: 1))); - $this->fail('Connecting should not have succeeded.'); - } catch (ConnectionException) { - $this->assertSame(1, $this->spawns()); - } - } - - /** - * @return array - */ - private function environment(int $failing): array - { - return [ - 'MCP_SPAWN_COUNTER' => $this->counter, - 'MCP_FAIL_SPAWNS' => (string) $failing, - ]; - } - - private function spawns(): int - { - return (int) file_get_contents($this->counter); - } -} diff --git a/tests/Integration/RootsTest.php b/tests/Integration/RootsTest.php deleted file mode 100644 index 6dfd0650..00000000 --- a/tests/Integration/RootsTest.php +++ /dev/null @@ -1,98 +0,0 @@ -connect('roots', $this->clientExposing( - new Root('file:///workspace/app', 'App'), - new Root('file:///workspace/docs', 'Docs'), - )); - - $result = $client->callTool('inspect_roots'); - - $this->assertInstanceOf(TextContent::class, $result->content[0]); - $this->assertSame('file:///workspace/app (App), file:///workspace/docs (Docs)', $result->content[0]->text); - } - - #[TestDox('an empty root list is a valid answer, not a failure')] - public function testEmptyRootList(): void - { - $client = $this->connect('roots', $this->clientExposing()); - - $result = $client->callTool('inspect_roots'); - - $this->assertInstanceOf(TextContent::class, $result->content[0]); - $this->assertSame('', $result->content[0]->text); - } - - #[TestDox('a client that does not advertise roots is not asked')] - public function testCapabilityIsVisibleToTheServer(): void - { - $client = $this->connect('roots'); - - $result = $client->callTool('inspect_roots'); - - $this->assertInstanceOf(TextContent::class, $result->content[0]); - $this->assertSame('unsupported', $result->content[0]->text); - } - - #[TestDox('the client can announce that its roots changed')] - public function testRootsListChangedNotification(): void - { - $client = $this->connect('roots', $this->clientExposing(new Root('file:///workspace'))); - - // A notification has no reply, so what this pins down is that sending - // one mid-session leaves the connection usable. - $client->sendRootsListChanged(); - - $this->assertTrue($client->isConnected()); - $this->assertInstanceOf(TextContent::class, $client->callTool('inspect_roots')->content[0]); - } - - private function clientExposing(Root ...$roots): ClientBuilder - { - $callback = new class(array_values($roots)) implements RootsCallbackInterface { - /** @param list $roots */ - public function __construct(private readonly array $roots) - { - } - - public function __invoke(ListRootsRequest $request): ListRootsResult - { - return new ListRootsResult($this->roots); - } - }; - - return $this->clientBuilder() - ->setCapabilities(new ClientCapabilities(roots: true, rootsListChanged: true)) - ->addRequestHandler(new ListRootsRequestHandler($callback)); - } -} diff --git a/tests/Integration/SamplingTest.php b/tests/Integration/SamplingTest.php deleted file mode 100644 index c581cf86..00000000 --- a/tests/Integration/SamplingTest.php +++ /dev/null @@ -1,98 +0,0 @@ -connect('sampling', $this->clientSampling()); - - $result = $client->callTool('summarize', ['text' => 'a long report']); - - $this->assertInstanceOf(TextContent::class, $result->content[0]); - $this->assertSame('test-model said: a long report', $result->content[0]->text); - } - - #[TestDox('the prompt the tool passed arrives at the client')] - public function testPromptReachesTheClient(): void - { - // The client stays in this process, so what it was asked can be - // collected by reference even though the tool asking runs in another. - /** @var \ArrayObject $seen */ - $seen = new \ArrayObject(); - $client = $this->connect('sampling', $this->clientSampling($seen)); - - $client->callTool('summarize', ['text' => 'inspect me']); - - $this->assertCount(1, $seen); - $this->assertInstanceOf(TextContent::class, $seen[0]->messages[0]->content); - $this->assertSame('inspect me', $seen[0]->messages[0]->content->text); - $this->assertSame(64, $seen[0]->maxTokens); - } - - #[TestDox('a client that cannot sample refuses instead of stalling the tool')] - public function testClientWithoutSamplingRefuses(): void - { - // The gateway has no supportsSampling() to consult, so the tool finds - // out by asking and the refusal surfaces as a ClientException. - $client = $this->connect('sampling'); - - $result = $client->callTool('summarize', ['text' => 'anything']); - - $this->assertInstanceOf(TextContent::class, $result->content[0]); - $this->assertSame('Client does not handle "sampling/createMessage" requests.', $result->content[0]->text); - } - - /** - * @param \ArrayObject|null $seen collects what the server asked for - */ - private function clientSampling(?\ArrayObject $seen = null): ClientBuilder - { - $callback = new class($seen ?? new \ArrayObject()) implements SamplingCallbackInterface { - /** @param \ArrayObject $seen */ - public function __construct(private readonly \ArrayObject $seen) - { - } - - public function __invoke(CreateSamplingMessageRequest $request): CreateSamplingMessageResult - { - $this->seen[] = $request; - - $prompt = $request->messages[0]->content; - \assert($prompt instanceof TextContent); - - return new CreateSamplingMessageResult(Role::Assistant, new TextContent($prompt->text), 'test-model'); - } - }; - - return $this->clientBuilder() - ->setCapabilities(new ClientCapabilities(sampling: true)) - ->addRequestHandler(new SamplingRequestHandler($callback)); - } -} diff --git a/tests/Integration/SamplingToolsTest.php b/tests/Integration/SamplingToolsTest.php deleted file mode 100644 index 107f163b..00000000 --- a/tests/Integration/SamplingToolsTest.php +++ /dev/null @@ -1,135 +0,0 @@ -connect('sampling_tools', $this->clientSamplingWithTools()); - - $result = $client->callTool('weather_report', ['city' => 'Paris']); - - $this->assertInstanceOf(TextContent::class, $result->content[0]); - $this->assertSame('Paris: 18 C is the answer. (endTurn)', $result->content[0]->text); - } - - #[TestDox('the tools the server offered arrive at the client')] - public function testToolsReachTheClient(): void - { - /** @var \ArrayObject $seen */ - $seen = new \ArrayObject(); - $client = $this->connect('sampling_tools', $this->clientSamplingWithTools($seen)); - - $client->callTool('weather_report', ['city' => 'Paris']); - - $this->assertCount(2, $seen); - $this->assertSame('get_weather', $seen[0]->tools[0]->name); - - // Second turn carries the assistant's tool use and the server's tool result. - $this->assertCount(3, $seen[1]->messages); - $this->assertInstanceOf(ToolUseContent::class, $seen[1]->messages[1]->getContentBlocks()[0]); - $toolResult = $seen[1]->messages[2]->getContentBlocks()[0]; - $this->assertInstanceOf(ToolResultContent::class, $toolResult); - $this->assertSame('call-1', $toolResult->toolUseId); - } - - #[TestDox('a client that did not advertise sampling.tools is never sent tools')] - public function testClientWithoutSamplingToolsIsNotOfferedTools(): void - { - $client = $this->connect('sampling_tools', $this->clientBuilder() - ->setCapabilities(new ClientCapabilities(sampling: true)) - ->addRequestHandler(new SamplingRequestHandler($this->neverCalled()))); - - $result = $client->callTool('weather_report', ['city' => 'Paris']); - - $this->assertInstanceOf(TextContent::class, $result->content[0]); - $this->assertSame('client cannot use tools during sampling', $result->content[0]->text); - } - - /** - * @param \ArrayObject|null $seen collects what the server asked for - */ - private function clientSamplingWithTools(?\ArrayObject $seen = null): ClientBuilder - { - $callback = new class($seen ?? new \ArrayObject()) implements SamplingCallbackInterface { - /** @param \ArrayObject $seen */ - public function __construct(private readonly \ArrayObject $seen) - { - } - - public function __invoke(CreateSamplingMessageRequest $request): CreateSamplingMessageResult - { - $this->seen[] = $request; - - $lastMessage = $request->messages[\count($request->messages) - 1]; - $answeredTools = array_filter( - $lastMessage->getContentBlocks(), - static fn ($block): bool => $block instanceof ToolResultContent, - ); - - // First turn: ask for the tool. Second: answer from its result. - if ([] === $answeredTools) { - return new CreateSamplingMessageResult( - Role::Assistant, - [new ToolUseContent('call-1', 'get_weather', ['city' => 'Paris'])], - 'test-model', - 'toolUse', - ); - } - - $toolResult = reset($answeredTools); - $text = $toolResult->content[0]; - \assert($text instanceof TextContent); - - return new CreateSamplingMessageResult( - Role::Assistant, - new TextContent(\sprintf('%s is the answer.', $text->text)), - 'test-model', - 'endTurn', - ); - } - }; - - return $this->clientBuilder() - ->setCapabilities(new ClientCapabilities(sampling: true, samplingTools: true)) - ->addRequestHandler(new SamplingRequestHandler($callback)); - } - - private function neverCalled(): SamplingCallbackInterface - { - return new class implements SamplingCallbackInterface { - public function __invoke(CreateSamplingMessageRequest $request): CreateSamplingMessageResult - { - throw new \LogicException('The server must not sample a client that cannot use tools.'); - } - }; - } -} diff --git a/tests/Unit/Capability/Attribute/CompletionProviderFixture.php b/tests/Unit/Capability/Attribute/CompletionProviderFixture.php deleted file mode 100644 index 1f4f649e..00000000 --- a/tests/Unit/Capability/Attribute/CompletionProviderFixture.php +++ /dev/null @@ -1,30 +0,0 @@ - str_starts_with($item, $currentValue)); - } -} diff --git a/tests/Unit/Capability/Attribute/CompletionProviderTest.php b/tests/Unit/Capability/Attribute/CompletionProviderTest.php deleted file mode 100644 index 19a78750..00000000 --- a/tests/Unit/Capability/Attribute/CompletionProviderTest.php +++ /dev/null @@ -1,85 +0,0 @@ -assertSame(CompletionProviderFixture::class, $attribute->provider); - $this->assertNull($attribute->values); - $this->assertNull($attribute->enum); - } - - public function testCanBeConstructedWithProviderInstance(): void - { - $instance = new CompletionProviderFixture(); - $attribute = new CompletionProvider(provider: $instance); - - $this->assertSame($instance, $attribute->provider); - $this->assertNull($attribute->values); - $this->assertNull($attribute->enum); - } - - public function testCanBeConstructedWithValuesArray(): void - { - $values = ['draft', 'published', 'archived']; - $attribute = new CompletionProvider(values: $values); - - $this->assertNull($attribute->provider); - $this->assertSame($values, $attribute->values); - $this->assertNull($attribute->enum); - } - - public function testCanBeConstructedWithEnumClass(): void - { - $attribute = new CompletionProvider(enum: StatusEnum::class); - - $this->assertNull($attribute->provider); - $this->assertNull($attribute->values); - $this->assertSame(StatusEnum::class, $attribute->enum); - } - - public function testThrowsExceptionWhenNoParametersProvided(): void - { - $this->expectException(\InvalidArgumentException::class); - $this->expectExceptionMessage('Only one of provider, values, or enum can be set'); - new CompletionProvider(); - } - - public function testThrowsExceptionWhenMultipleParametersProvided(): void - { - $this->expectException(\InvalidArgumentException::class); - $this->expectExceptionMessage('Only one of provider, values, or enum can be set'); - new CompletionProvider( - provider: CompletionProviderFixture::class, - values: ['test'] - ); - } - - public function testThrowsExceptionWhenAllParametersProvided(): void - { - $this->expectException(\InvalidArgumentException::class); - $this->expectExceptionMessage('Only one of provider, values, or enum can be set'); - new CompletionProvider( - provider: CompletionProviderFixture::class, - values: ['test'], - enum: StatusEnum::class - ); - } -} diff --git a/tests/Unit/Capability/Attribute/McpPromptTest.php b/tests/Unit/Capability/Attribute/McpPromptTest.php deleted file mode 100644 index d74cbcfe..00000000 --- a/tests/Unit/Capability/Attribute/McpPromptTest.php +++ /dev/null @@ -1,52 +0,0 @@ -assertSame($name, $attribute->name); - $this->assertSame($description, $attribute->description); - } - - public function testInstantiatesWithNullValuesForNameAndDescription(): void - { - // Arrange & Act - $attribute = new McpPrompt(name: null, description: null); - - // Assert - $this->assertNull($attribute->name); - $this->assertNull($attribute->description); - } - - public function testInstantiatesWithMissingOptionalArguments(): void - { - // Arrange & Act - $attribute = new McpPrompt(); // Use default constructor values - - // Assert - $this->assertNull($attribute->name); - $this->assertNull($attribute->description); - } -} diff --git a/tests/Unit/Capability/Attribute/McpResourceTemplateTest.php b/tests/Unit/Capability/Attribute/McpResourceTemplateTest.php deleted file mode 100644 index 3bbccb88..00000000 --- a/tests/Unit/Capability/Attribute/McpResourceTemplateTest.php +++ /dev/null @@ -1,71 +0,0 @@ -assertSame($uriTemplate, $attribute->uriTemplate); - $this->assertSame($name, $attribute->name); - $this->assertSame($description, $attribute->description); - $this->assertSame($mimeType, $attribute->mimeType); - } - - public function testInstantiatesWithNullValuesForNameAndDescription(): void - { - // Arrange & Act - $attribute = new McpResourceTemplate( - uriTemplate: 'test://{id}', // uriTemplate is required - name: null, - description: null, - mimeType: null, - ); - - // Assert - $this->assertSame('test://{id}', $attribute->uriTemplate); - $this->assertNull($attribute->name); - $this->assertNull($attribute->description); - $this->assertNull($attribute->mimeType); - } - - public function testInstantiatesWithMissingOptionalArguments(): void - { - // Arrange & Act - $uriTemplate = 'tmpl://{key}'; - $attribute = new McpResourceTemplate(uriTemplate: $uriTemplate); - - // Assert - $this->assertSame($uriTemplate, $attribute->uriTemplate); - $this->assertNull($attribute->name); - $this->assertNull($attribute->description); - $this->assertNull($attribute->mimeType); - } -} diff --git a/tests/Unit/Capability/Attribute/McpResourceTest.php b/tests/Unit/Capability/Attribute/McpResourceTest.php deleted file mode 100644 index 423a4122..00000000 --- a/tests/Unit/Capability/Attribute/McpResourceTest.php +++ /dev/null @@ -1,77 +0,0 @@ -assertSame($uri, $attribute->uri); - $this->assertSame($name, $attribute->name); - $this->assertSame($description, $attribute->description); - $this->assertSame($mimeType, $attribute->mimeType); - $this->assertSame($size, $attribute->size); - } - - public function testInstantiatesWithNullValuesForNameAndDescription(): void - { - // Arrange & Act - $attribute = new McpResource( - uri: 'file:///test', // URI is required - name: null, - description: null, - mimeType: null, - size: null, - ); - - // Assert - $this->assertSame('file:///test', $attribute->uri); - $this->assertNull($attribute->name); - $this->assertNull($attribute->description); - $this->assertNull($attribute->mimeType); - $this->assertNull($attribute->size); - } - - public function testInstantiatesWithMissingOptionalArguments(): void - { - // Arrange & Act - $uri = 'file:///only-uri'; - $attribute = new McpResource(uri: $uri); - - // Assert - $this->assertSame($uri, $attribute->uri); - $this->assertNull($attribute->name); - $this->assertNull($attribute->description); - $this->assertNull($attribute->mimeType); - $this->assertNull($attribute->size); - } -} diff --git a/tests/Unit/Capability/Attribute/McpToolTest.php b/tests/Unit/Capability/Attribute/McpToolTest.php deleted file mode 100644 index e6314581..00000000 --- a/tests/Unit/Capability/Attribute/McpToolTest.php +++ /dev/null @@ -1,94 +0,0 @@ -assertSame($name, $attribute->name); - $this->assertSame($description, $attribute->description); - } - - public function testInstantiatesWithNullValuesForNameDescriptionAndOutputSchema(): void - { - // Arrange & Act - $attribute = new McpTool(name: null, description: null, outputSchema: null); - - // Assert - $this->assertNull($attribute->name); - $this->assertNull($attribute->description); - $this->assertNull($attribute->outputSchema); - } - - public function testInstantiatesWithMissingOptionalArguments(): void - { - // Arrange & Act - $attribute = new McpTool(); // Use default constructor values - - // Assert - $this->assertNull($attribute->name); - $this->assertNull($attribute->description); - $this->assertNull($attribute->outputSchema); - } - - public function testInstantiatesWithTitle(): void - { - $attribute = new McpTool(name: 'n', title: 'T'); - - $this->assertSame('n', $attribute->name); - $this->assertSame('T', $attribute->title); - } - - public function testDefaultTitleIsNull(): void - { - $attribute = new McpTool(); - - $this->assertNull($attribute->title); - } - - public function testInstantiatesWithOutputSchema(): void - { - // Arrange - $name = 'test-tool-name'; - $description = 'This is a test description.'; - $outputSchema = [ - 'type' => 'object', - 'properties' => [ - 'result' => [ - 'type' => 'string', - 'description' => 'The result of the operation', - ], - ], - 'required' => ['result'], - ]; - - // Act - $attribute = new McpTool(name: $name, description: $description, outputSchema: $outputSchema); - - // Assert - $this->assertSame($name, $attribute->name); - $this->assertSame($description, $attribute->description); - $this->assertSame($outputSchema, $attribute->outputSchema); - } -} diff --git a/tests/Unit/Capability/Discovery/CachedDiscovererTest.php b/tests/Unit/Capability/Discovery/CachedDiscovererTest.php deleted file mode 100644 index 75ffd88c..00000000 --- a/tests/Unit/Capability/Discovery/CachedDiscovererTest.php +++ /dev/null @@ -1,94 +0,0 @@ -createMock(CacheInterface::class); - $cache->expects($this->once()) - ->method('get') - ->willReturn(null); - - $cache->expects($this->once()) - ->method('set') - ->willReturn(true); - - $cachedDiscoverer = new CachedDiscoverer( - $discoverer, - $cache, - new NullLogger() - ); - - $result = $cachedDiscoverer->discover('/test/path', ['.'], []); - $this->assertInstanceOf(DiscoveryState::class, $result); - } - - public function testCachedDiscovererReturnsCachedResults(): void - { - $discoverer = new Discoverer(); - - $cache = $this->createMock(CacheInterface::class); - $cachedState = new DiscoveryState(); - $cache->expects($this->once()) - ->method('get') - ->willReturn($cachedState); - - $cache->expects($this->never()) - ->method('set'); - - $cachedDiscoverer = new CachedDiscoverer( - $discoverer, - $cache, - new NullLogger() - ); - - $result = $cachedDiscoverer->discover('/test/path', ['.'], []); - $this->assertInstanceOf(DiscoveryState::class, $result); - } - - public function testCacheKeyGeneration(): void - { - $discoverer = new Discoverer(); - - $cache = $this->createMock(CacheInterface::class); - - $cache->expects($this->exactly(2)) - ->method('get') - ->willReturn(null); - - $cache->expects($this->exactly(2)) - ->method('set') - ->willReturn(true); - - $cachedDiscoverer = new CachedDiscoverer( - $discoverer, - $cache, - new NullLogger() - ); - - $result1 = $cachedDiscoverer->discover('/path1', ['.'], []); - $result2 = $cachedDiscoverer->discover('/path2', ['.'], []); - $this->assertInstanceOf(DiscoveryState::class, $result1); - $this->assertInstanceOf(DiscoveryState::class, $result2); - } -} diff --git a/tests/Unit/Capability/Discovery/DiscovererToolTitleTest.php b/tests/Unit/Capability/Discovery/DiscovererToolTitleTest.php deleted file mode 100644 index 93754256..00000000 --- a/tests/Unit/Capability/Discovery/DiscovererToolTitleTest.php +++ /dev/null @@ -1,33 +0,0 @@ -discover(__DIR__, ['Fixtures']); - - $tools = $discovery->getTools(); - - $this->assertArrayHasKey('greet_user', $tools); - $toolRef = $tools['greet_user']; - $this->assertInstanceOf(ToolReference::class, $toolRef); - $this->assertSame('Greet User', $toolRef->tool->title); - } -} diff --git a/tests/Unit/Capability/Discovery/DiscoveryStateTest.php b/tests/Unit/Capability/Discovery/DiscoveryStateTest.php deleted file mode 100644 index e40c722d..00000000 --- a/tests/Unit/Capability/Discovery/DiscoveryStateTest.php +++ /dev/null @@ -1,164 +0,0 @@ -tool('t1'); - $t2 = $this->tool('t2'); - - $owned = new DiscoveryState( - tools: ['t1' => $t1, 't2' => $t2], - ); - $next = new DiscoveryState( - tools: ['t2' => $this->tool('t2'), 't3' => $this->tool('t3')], - ); - - $obsolete = $owned->obsoletedBy($next); - - $this->assertSame(['t1' => $t1], $obsolete->getTools()); - } - - public function testObsoletedByIsAsymmetricAndIgnoresValuesOnSharedKeys(): void - { - // Same key, different reference instance — must NOT be reported as obsolete. - $owned = new DiscoveryState( - tools: ['t' => $this->tool('t')], - ); - $next = new DiscoveryState( - tools: ['t' => $this->tool('t')], - ); - - $this->assertTrue($owned->obsoletedBy($next)->isEmpty()); - } - - public function testObsoletedByOnEmptyNextReturnsAllOwned(): void - { - $owned = new DiscoveryState( - tools: ['t' => $this->tool('t')], - resources: ['r://x' => $this->resource('r://x')], - prompts: ['p' => $this->prompt('p')], - resourceTemplates: ['x://{id}' => $this->template('x://{id}')], - ); - - $obsolete = $owned->obsoletedBy(new DiscoveryState()); - - $this->assertSame(['t'], array_keys($obsolete->getTools())); - $this->assertSame(['r://x'], array_keys($obsolete->getResources())); - $this->assertSame(['p'], array_keys($obsolete->getPrompts())); - $this->assertSame(['x://{id}'], array_keys($obsolete->getResourceTemplates())); - } - - public function testObsoletedByOnEmptyOwnedReturnsEmpty(): void - { - $next = new DiscoveryState( - tools: ['t' => $this->tool('t')], - ); - - $this->assertTrue((new DiscoveryState())->obsoletedBy($next)->isEmpty()); - } - - public function testObsoletedByKeepsKindsIndependent(): void - { - // A key shared across different kinds must not cancel out. - $owned = new DiscoveryState( - tools: ['shared' => $this->tool('shared')], - prompts: ['shared' => $this->prompt('shared')], - ); - $next = new DiscoveryState( - tools: ['shared' => $this->tool('shared')], - // 'shared' prompt is gone. - ); - - $obsolete = $owned->obsoletedBy($next); - - $this->assertSame([], $obsolete->getTools()); - $this->assertSame(['shared'], array_keys($obsolete->getPrompts())); - } - - public function testObsoletedByAcrossAllKindsAtOnce(): void - { - $owned = new DiscoveryState( - tools: ['keep_t' => $this->tool('keep_t'), 'drop_t' => $this->tool('drop_t')], - resources: ['r://keep' => $this->resource('r://keep'), 'r://drop' => $this->resource('r://drop')], - prompts: ['keep_p' => $this->prompt('keep_p'), 'drop_p' => $this->prompt('drop_p')], - resourceTemplates: ['keep://{id}' => $this->template('keep://{id}'), 'drop://{id}' => $this->template('drop://{id}')], - ); - $next = new DiscoveryState( - tools: ['keep_t' => $this->tool('keep_t')], - resources: ['r://keep' => $this->resource('r://keep')], - prompts: ['keep_p' => $this->prompt('keep_p')], - resourceTemplates: ['keep://{id}' => $this->template('keep://{id}')], - ); - - $obsolete = $owned->obsoletedBy($next); - - $this->assertSame(['drop_t'], array_keys($obsolete->getTools())); - $this->assertSame(['r://drop'], array_keys($obsolete->getResources())); - $this->assertSame(['drop_p'], array_keys($obsolete->getPrompts())); - $this->assertSame(['drop://{id}'], array_keys($obsolete->getResourceTemplates())); - } - - private function tool(string $name): ToolReference - { - return new ToolReference( - new Tool( - name: $name, - title: null, - inputSchema: ['type' => 'object', 'properties' => [], 'required' => null], - description: null, - annotations: null, - icons: null, - meta: null, - outputSchema: null, - ), - static fn () => null, - ); - } - - private function resource(string $uri): ResourceReference - { - return new ResourceReference( - new ResourceDefinition(uri: $uri, name: 'r', description: null, mimeType: 'text/plain'), - static fn () => null, - ); - } - - private function prompt(string $name): PromptReference - { - return new PromptReference( - new Prompt(name: $name, description: null, arguments: []), - static fn () => [], - ); - } - - private function template(string $uriTemplate): ResourceTemplateReference - { - return new ResourceTemplateReference( - new ResourceTemplate(uriTemplate: $uriTemplate, name: 'tpl', description: null, mimeType: 'text/plain'), - static fn () => null, - ); - } -} diff --git a/tests/Unit/Capability/Discovery/DiscoveryTest.php b/tests/Unit/Capability/Discovery/DiscoveryTest.php deleted file mode 100644 index b4197262..00000000 --- a/tests/Unit/Capability/Discovery/DiscoveryTest.php +++ /dev/null @@ -1,176 +0,0 @@ -discoverer = new Discoverer(); - } - - public function testDiscoversAllElementTypesCorrectlyFromFixtureFiles(): void - { - $discovery = $this->discoverer->discover(__DIR__, ['Fixtures'], [], ['*.php', '*.inc']); - - $tools = $discovery->getTools(); - $this->assertCount(5, $tools); - - $this->assertArrayHasKey('greet_user', $tools); - $this->assertEquals('greet_user', $tools['greet_user']->tool->name); - $this->assertEquals('Greets a user by name.', $tools['greet_user']->tool->description); - $this->assertEquals([DiscoverableToolHandler::class, 'greet'], $tools['greet_user']->handler); - $this->assertArrayHasKey('name', $tools['greet_user']->tool->inputSchema['properties'] ?? []); - - $this->assertArrayHasKey('repeatAction', $tools); - $this->assertEquals('A tool with more complex parameters and inferred name/description.', $tools['repeatAction']->tool->description); - $this->assertTrue($tools['repeatAction']->tool->annotations->readOnlyHint); - $this->assertEquals(['count', 'loudly', 'mode'], array_keys($tools['repeatAction']->tool->inputSchema['properties'] ?? [])); - - $this->assertArrayHasKey('InvokableCalculator', $tools); - $this->assertInstanceOf(ToolReference::class, $tools['InvokableCalculator']); - $this->assertEquals([InvocableToolFixture::class, '__invoke'], $tools['InvokableCalculator']->handler); - - $this->assertArrayHasKey('inc_file_name_tool', $tools); - $this->assertEquals([AlternativeFileNameToolHandler::class, 'run'], $tools['inc_file_name_tool']->handler); - - $this->assertArrayNotHasKey('private_tool_should_be_ignored', $tools); - $this->assertArrayNotHasKey('protected_tool_should_be_ignored', $tools); - $this->assertArrayNotHasKey('static_tool_should_be_ignored', $tools); - - $resources = $discovery->getResources(); - $this->assertCount(3, $resources); - - $this->assertArrayHasKey('app://info/version', $resources); - $this->assertEquals('app_version', $resources['app://info/version']->resource->name); - $this->assertEquals('text/plain', $resources['app://info/version']->resource->mimeType); - - $this->assertArrayHasKey('invokable://config/status', $resources); - $this->assertEquals([InvocableResourceFixture::class, '__invoke'], $resources['invokable://config/status']->handler); - - $prompts = $discovery->getPrompts(); - $this->assertCount(4, $prompts); - - $this->assertArrayHasKey('creative_story_prompt', $prompts); - $this->assertCount(2, $prompts['creative_story_prompt']->prompt->arguments); - $this->assertEquals(CompletionProviderFixture::class, $prompts['creative_story_prompt']->completionProviders['genre']); - - $this->assertArrayHasKey('simpleQuestionPrompt', $prompts); - - $this->assertArrayHasKey('InvokableGreeterPrompt', $prompts); - $this->assertEquals([InvocablePromptFixture::class, '__invoke'], $prompts['InvokableGreeterPrompt']->handler); - - $this->assertArrayHasKey('content_creator', $prompts); - $this->assertCount(3, $prompts['content_creator']->completionProviders); - - $templates = $discovery->getResourceTemplates(); - $this->assertCount(4, $templates); - - $this->assertArrayHasKey('product://{region}/details/{productId}', $templates); - $this->assertEquals('product_details_template', $templates['product://{region}/details/{productId}']->resourceTemplate->name); - $this->assertEquals(CompletionProviderFixture::class, $templates['product://{region}/details/{productId}']->completionProviders['region']); - $this->assertEqualsCanonicalizing(['region', 'productId'], $templates['product://{region}/details/{productId}']->getVariableNames()); - - $this->assertArrayHasKey('invokable://user-profile/{userId}', $templates); - $this->assertEquals([InvocableResourceTemplateFixture::class, '__invoke'], $templates['invokable://user-profile/{userId}']->handler); - } - - public function testDoesNotDiscoverElementsFromExcludedDirectories(): void - { - $discovery = $this->discoverer->discover(__DIR__, ['Fixtures']); - $this->assertArrayHasKey('hidden_subdir_tool', $discovery->getTools()); - - $discovery = $this->discoverer->discover(__DIR__, ['Fixtures'], ['SubDir']); - $this->assertArrayNotHasKey('hidden_subdir_tool', $discovery->getTools()); - } - - public function testHandlesEmptyDirectoriesOrDirectoriesWithNoPhpFiles(): void - { - $discovery = $this->discoverer->discover(__DIR__, ['EmptyDir']); - - $this->assertTrue($discovery->isEmpty()); - } - - public function testHandlesDefaultAndOverriddenFileNamePatterns(): void - { - $discovery = $this->discoverer->discover(__DIR__, ['Fixtures']); - $this->assertArrayHasKey('greet_user', $discovery->getTools()); - $this->assertArrayNotHasKey('inc_file_name_tool', $discovery->getTools()); - - $discovery = $this->discoverer->discover(__DIR__, ['Fixtures'], [], []); - $this->assertArrayHasKey('greet_user', $discovery->getTools()); - $this->assertArrayNotHasKey('inc_file_name_tool', $discovery->getTools()); - - $discovery = $this->discoverer->discover(__DIR__, ['Fixtures'], [], ['*.php', '*.inc']); - $this->assertArrayHasKey('greet_user', $discovery->getTools()); - $this->assertArrayHasKey('inc_file_name_tool', $discovery->getTools()); - - $discovery = $this->discoverer->discover(__DIR__, ['Fixtures'], [], ['*.inc']); - $this->assertArrayNotHasKey('greet_user', $discovery->getTools()); - $this->assertArrayHasKey('inc_file_name_tool', $discovery->getTools()); - } - - public function testCorrectlyInfersNamesAndDescriptionsFromMethodsOrClassesIfNotSetInAttribute(): void - { - $discovery = $this->discoverer->discover(__DIR__, ['Fixtures']); - - $this->assertArrayHasKey('repeatAction', $tools = $discovery->getTools()); - $this->assertEquals('repeatAction', $tools['repeatAction']->tool->name); - $this->assertEquals('A tool with more complex parameters and inferred name/description.', $tools['repeatAction']->tool->description); - - $this->assertArrayHasKey('simpleQuestionPrompt', $prompts = $discovery->getPrompts()); - $this->assertEquals('simpleQuestionPrompt', $prompts['simpleQuestionPrompt']->prompt->name); - $this->assertNull($prompts['simpleQuestionPrompt']->prompt->description); - - $this->assertArrayHasKey('InvokableCalculator', $tools); - $this->assertEquals('InvokableCalculator', $tools['InvokableCalculator']->tool->name); - $this->assertEquals('An invokable calculator tool.', $tools['InvokableCalculator']->tool->description); - } - - public function testDiscoversEnhancedCompletionProvidersWithValuesAndEnumAttributes(): void - { - $discovery = $this->discoverer->discover(__DIR__, ['Fixtures']); - - $this->assertArrayHasKey('content_creator', $prompts = $discovery->getPrompts()); - $this->assertCount(3, $prompts['content_creator']->completionProviders); - - $typeProvider = $prompts['content_creator']->completionProviders['type']; - $this->assertInstanceOf(ListCompletionProvider::class, $typeProvider); - - $statusProvider = $prompts['content_creator']->completionProviders['status']; - $this->assertInstanceOf(EnumCompletionProvider::class, $statusProvider); - - $priorityProvider = $prompts['content_creator']->completionProviders['priority']; - $this->assertInstanceOf(EnumCompletionProvider::class, $priorityProvider); - - $this->assertArrayHasKey('content://{category}/{slug}', $templates = $discovery->getResourceTemplates()); - $this->assertCount(1, $templates['content://{category}/{slug}']->completionProviders); - - $categoryProvider = $templates['content://{category}/{slug}']->completionProviders['category']; - $this->assertInstanceOf(ListCompletionProvider::class, $categoryProvider); - } -} diff --git a/tests/Unit/Capability/Discovery/DocBlockParserTest.php b/tests/Unit/Capability/Discovery/DocBlockParserTest.php deleted file mode 100644 index a2a832ad..00000000 --- a/tests/Unit/Capability/Discovery/DocBlockParserTest.php +++ /dev/null @@ -1,129 +0,0 @@ -parser = new DocBlockParser(); - } - - public function testGetDescriptionReturnsCorrectDescription(): void - { - $method = new \ReflectionMethod(DocBlockTestFixture::class, 'methodWithSummaryAndDescription'); - $docComment = $method->getDocComment() ?: null; - $docBlock = $this->parser->parseDocBlock($docComment); - $expectedDesc = "Summary line here.\n\nThis is a longer description spanning\nmultiple lines.\nIt might contain *markdown* or `code`."; - $this->assertEquals($expectedDesc, $this->parser->getDescription($docBlock)); - - $method2 = new \ReflectionMethod(DocBlockTestFixture::class, 'methodWithSummaryOnly'); - $docComment2 = $method2->getDocComment() ?: null; - $docBlock2 = $this->parser->parseDocBlock($docComment2); - $this->assertEquals('Simple summary line.', $this->parser->getDescription($docBlock2)); - } - - public function testGetParamTagsReturnsStructuredParamInfo(): void - { - $method = new \ReflectionMethod(DocBlockTestFixture::class, 'methodWithParams'); - $docComment = $method->getDocComment() ?: null; - $docBlock = $this->parser->parseDocBlock($docComment); - $params = $this->parser->getParamTags($docBlock); - - $this->assertCount(6, $params); - $this->assertArrayHasKey('$param1', $params); - $this->assertArrayHasKey('$param2', $params); - $this->assertArrayHasKey('$param3', $params); - $this->assertArrayHasKey('$param4', $params); - $this->assertArrayHasKey('$param5', $params); - $this->assertArrayHasKey('$param6', $params); - - $this->assertInstanceOf(Param::class, $params['$param1']); - $this->assertEquals('param1', $params['$param1']->getVariableName()); - $this->assertEquals('string', $this->parser->getParamTypeString($params['$param1'])); - $this->assertEquals('description for string param', $this->parser->getParamDescription($params['$param1'])); - - $this->assertInstanceOf(Param::class, $params['$param2']); - $this->assertEquals('param2', $params['$param2']->getVariableName()); - $this->assertEquals('int|null', $this->parser->getParamTypeString($params['$param2'])); - $this->assertEquals('description for nullable int param', $this->parser->getParamDescription($params['$param2'])); - - $this->assertInstanceOf(Param::class, $params['$param3']); - $this->assertEquals('param3', $params['$param3']->getVariableName()); - $this->assertEquals('bool', $this->parser->getParamTypeString($params['$param3'])); - $this->assertEquals('nothing to say', $this->parser->getParamDescription($params['$param3'])); - - $this->assertInstanceOf(Param::class, $params['$param4']); - $this->assertEquals('param4', $params['$param4']->getVariableName()); - $this->assertEquals('mixed', $this->parser->getParamTypeString($params['$param4'])); - $this->assertEquals('Missing type', $this->parser->getParamDescription($params['$param4'])); - - $this->assertInstanceOf(Param::class, $params['$param5']); - $this->assertEquals('param5', $params['$param5']->getVariableName()); - // Remove if when dropping support for phpdocumentor/reflection-docblock:^5.6 - if (InstalledVersions::satisfies(new VersionParser(), 'phpdocumentor/reflection-docblock', '^6.0')) { - $this->assertEquals('array', $this->parser->getParamTypeString($params['$param5'])); - } else { - $this->assertEquals('array', $this->parser->getParamTypeString($params['$param5'])); - } - $this->assertEquals('array description', $this->parser->getParamDescription($params['$param5'])); - - $this->assertInstanceOf(Param::class, $params['$param6']); - $this->assertEquals('param6', $params['$param6']->getVariableName()); - $this->assertEquals('stdClass', $this->parser->getParamTypeString($params['$param6'])); - $this->assertEquals('object param', $this->parser->getParamDescription($params['$param6'])); - } - - public function testGetTagsByNameReturnsSpecificTags(): void - { - $method = new \ReflectionMethod(DocBlockTestFixture::class, 'methodWithMultipleTags'); - $docComment = $method->getDocComment() ?: null; - $docBlock = $this->parser->parseDocBlock($docComment); - - $this->assertInstanceOf(DocBlock::class, $docBlock); - - $deprecatedTags = $docBlock->getTagsByName('deprecated'); - $this->assertCount(1, $deprecatedTags); - $this->assertInstanceOf(Deprecated::class, $deprecatedTags[0]); - $this->assertEquals('use newMethod() instead', $deprecatedTags[0]->getDescription()->render()); - - $seeTags = $docBlock->getTagsByName('see'); - $this->assertCount(1, $seeTags); - $this->assertInstanceOf(See::class, $seeTags[0]); - $this->assertStringContainsString('DocBlockTestFixture::newMethod()', (string) $seeTags[0]->getReference()); - - $nonExistentTags = $docBlock->getTagsByName('nosuchtag'); - $this->assertEmpty($nonExistentTags); - } - - public function testHandlesMethodWithNoDocblockGracefully(): void - { - $method = new \ReflectionMethod(DocBlockTestFixture::class, 'methodWithNoDocBlock'); - $docComment = $method->getDocComment() ?: null; - $docBlock = $this->parser->parseDocBlock($docComment); - - $this->assertNull($docBlock); - $this->assertNull($this->parser->getDescription($docBlock)); - $this->assertEmpty($this->parser->getParamTags($docBlock)); - } -} diff --git a/tests/Unit/Capability/Discovery/DocBlockTestFixture.php b/tests/Unit/Capability/Discovery/DocBlockTestFixture.php deleted file mode 100644 index 7a753ddf..00000000 --- a/tests/Unit/Capability/Discovery/DocBlockTestFixture.php +++ /dev/null @@ -1,96 +0,0 @@ - $param5 array description - * @param \stdClass $param6 object param - */ - /* @phpstan-ignore-next-line missingType.parameter */ - public function methodWithParams(string $param1, ?int $param2, bool $param3, $param4, array $param5, \stdClass $param6): void - { - } - - /** - * Method with return tag. - * - * @return string the result of the operation - */ - public function methodWithReturn(): string - { - return ''; - } - - /** - * Method with multiple tags. - * - * @param float $value the value to process - * - * @return bool status of the operation - * - * @deprecated use newMethod() instead - * @see DocBlockTestFixture::newMethod() - */ - public function methodWithMultipleTags(float $value): bool - { - return true; - } - - /** - * Malformed docblock - missing closing. - */ - public function methodWithMalformedDocBlock(): void - { - } - - public function methodWithNoDocBlock(): void - { - } - - // Some other method needed for a @see tag perhaps - public function newMethod(): void - { - } -} diff --git a/tests/Unit/Capability/Discovery/Fixtures/AlternativeFileNameToolHandler.class.inc b/tests/Unit/Capability/Discovery/Fixtures/AlternativeFileNameToolHandler.class.inc deleted file mode 100644 index 67c78eb0..00000000 --- a/tests/Unit/Capability/Discovery/Fixtures/AlternativeFileNameToolHandler.class.inc +++ /dev/null @@ -1,22 +0,0 @@ - 'user', 'content' => "Write a {$genre} story about a lost robot, approximately {$lengthWords} words long."], - ]; - } - - #[McpPrompt] - public function simpleQuestionPrompt(string $question): array - { - return [ - ['role' => 'user', 'content' => $question], - ['role' => 'assistant', 'content' => 'I will try to answer that.'], - ]; - } -} diff --git a/tests/Unit/Capability/Discovery/Fixtures/DiscoverableResourceHandler.php b/tests/Unit/Capability/Discovery/Fixtures/DiscoverableResourceHandler.php deleted file mode 100644 index 1dfd9368..00000000 --- a/tests/Unit/Capability/Discovery/Fixtures/DiscoverableResourceHandler.php +++ /dev/null @@ -1,50 +0,0 @@ - 'dark', 'fontSize' => 14]; - } - - public function someOtherMethod(): void - { - } -} diff --git a/tests/Unit/Capability/Discovery/Fixtures/DiscoverableTemplateHandler.php b/tests/Unit/Capability/Discovery/Fixtures/DiscoverableTemplateHandler.php deleted file mode 100644 index f5d266b9..00000000 --- a/tests/Unit/Capability/Discovery/Fixtures/DiscoverableTemplateHandler.php +++ /dev/null @@ -1,51 +0,0 @@ - $productId, - 'name' => 'Product '.$productId, - 'region' => $region, - 'price' => ('EU' === $region ? '€' : '$').(hexdec(substr(md5($productId), 0, 4)) / 100), - ]; - } - - #[McpResourceTemplate(uriTemplate: 'file://{path}/{filename}.{extension}')] - public function getFileContent(string $path, string $filename, string $extension): string - { - return "Content of {$path}/{$filename}.{$extension}"; - } -} diff --git a/tests/Unit/Capability/Discovery/Fixtures/DiscoverableToolHandler.php b/tests/Unit/Capability/Discovery/Fixtures/DiscoverableToolHandler.php deleted file mode 100644 index f20cda58..00000000 --- a/tests/Unit/Capability/Discovery/Fixtures/DiscoverableToolHandler.php +++ /dev/null @@ -1,68 +0,0 @@ - $count, 'loudly' => $loudly, 'mode' => $mode->value, 'message' => 'Action repeated.']; - } - - // This method should NOT be discovered as a tool - public function internalHelperMethod(int $value): int - { - return $value * 2; - } - - #[McpTool(name: 'private_tool_should_be_ignored')] // On private method - private function aPrivateTool(): void - { - } - - #[McpTool(name: 'protected_tool_should_be_ignored')] // On protected method - protected function aProtectedTool(): void - { - } - - #[McpTool(name: 'static_tool_should_be_ignored')] // On static method - public static function aStaticTool(): void - { - } -} diff --git a/tests/Unit/Capability/Discovery/Fixtures/EnhancedCompletionHandler.php b/tests/Unit/Capability/Discovery/Fixtures/EnhancedCompletionHandler.php deleted file mode 100644 index 5ac67ddb..00000000 --- a/tests/Unit/Capability/Discovery/Fixtures/EnhancedCompletionHandler.php +++ /dev/null @@ -1,57 +0,0 @@ - 'user', 'content' => "Create a {$type} with status {$status} and priority {$priority}"], - ]; - } - - /** - * Resource template with list completion for categories. - */ - #[McpResourceTemplate( - uriTemplate: 'content://{category}/{slug}', - name: 'content_template' - )] - public function getContent( - #[CompletionProvider(values: ['news', 'blog', 'docs', 'api'])] - string $category, - string $slug, - ): array { - return [ - 'category' => $category, - 'slug' => $slug, - 'url' => "https://example.com/{$category}/{$slug}", - ]; - } -} diff --git a/tests/Unit/Capability/Discovery/Fixtures/InvocablePromptFixture.php b/tests/Unit/Capability/Discovery/Fixtures/InvocablePromptFixture.php deleted file mode 100644 index ab94e742..00000000 --- a/tests/Unit/Capability/Discovery/Fixtures/InvocablePromptFixture.php +++ /dev/null @@ -1,23 +0,0 @@ - 'user', 'content' => "Generate a short greeting for {$personName}."]]; - } -} diff --git a/tests/Unit/Capability/Discovery/Fixtures/InvocableResourceFixture.php b/tests/Unit/Capability/Discovery/Fixtures/InvocableResourceFixture.php deleted file mode 100644 index 8af2d214..00000000 --- a/tests/Unit/Capability/Discovery/Fixtures/InvocableResourceFixture.php +++ /dev/null @@ -1,23 +0,0 @@ - 'OK', 'load' => rand(1, 100) / 100.0]; - } -} diff --git a/tests/Unit/Capability/Discovery/Fixtures/InvocableResourceTemplateFixture.php b/tests/Unit/Capability/Discovery/Fixtures/InvocableResourceTemplateFixture.php deleted file mode 100644 index 9f95e2e8..00000000 --- a/tests/Unit/Capability/Discovery/Fixtures/InvocableResourceTemplateFixture.php +++ /dev/null @@ -1,23 +0,0 @@ - $userId, 'email' => "user{$userId}@example-invokable.com"]; - } -} diff --git a/tests/Unit/Capability/Discovery/Fixtures/InvocableToolFixture.php b/tests/Unit/Capability/Discovery/Fixtures/InvocableToolFixture.php deleted file mode 100644 index 7fa86568..00000000 --- a/tests/Unit/Capability/Discovery/Fixtures/InvocableToolFixture.php +++ /dev/null @@ -1,26 +0,0 @@ -assertInstanceOf(\ReflectionFunction::class, $resolved); - $this->assertEquals(1, $resolved->getNumberOfParameters()); - $this->assertInstanceOf(\ReflectionNamedType::class, $returnType = $resolved->getReturnType()); - $this->assertEquals('string', $returnType->getName()); - } - - public function testResolvesValidArrayHandler(): void - { - $handler = [ValidHandlerClass::class, 'publicMethod']; - $resolved = HandlerResolver::resolve($handler); - $this->assertInstanceOf(\ReflectionMethod::class, $resolved); - $this->assertEquals('publicMethod', $resolved->getName()); - $this->assertEquals(ValidHandlerClass::class, $resolved->getDeclaringClass()->getName()); - } - - public function testResolvesValidInstanceArrayHandler(): void - { - $handler = [new ValidHandlerClass(), 'publicMethod']; - $resolved = HandlerResolver::resolve($handler); - $this->assertInstanceOf(\ReflectionMethod::class, $resolved); - $this->assertEquals('publicMethod', $resolved->getName()); - $this->assertEquals(ValidHandlerClass::class, $resolved->getDeclaringClass()->getName()); - } - - public function testResolvesValidInvokableClassStringHandler(): void - { - $handler = ValidInvokableClass::class; - $resolved = HandlerResolver::resolve($handler); - $this->assertInstanceOf(\ReflectionMethod::class, $resolved); - $this->assertEquals('__invoke', $resolved->getName()); - $this->assertEquals(ValidInvokableClass::class, $resolved->getDeclaringClass()->getName()); - } - - public function testResolvesStaticMethodsForManualRegistration(): void - { - $handler = [ValidHandlerClass::class, 'staticMethod']; - $resolved = HandlerResolver::resolve($handler); - $this->assertInstanceOf(\ReflectionMethod::class, $resolved); - $this->assertEquals('staticMethod', $resolved->getName()); - $this->assertTrue($resolved->isStatic()); - } - - public function testThrowsForInvalidArrayHandlerFormatCount(): void - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Invalid array handler format. Expected [ClassName::class, \'methodName\'] or [$instance, \'methodName\'].'); - HandlerResolver::resolve([ValidHandlerClass::class]); /* @phpstan-ignore argument.type */ - } - - public function testThrowsForInvalidArrayHandlerFormatTypes(): void - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Invalid array handler format. Expected [ClassName::class, \'methodName\'] or [$instance, \'methodName\'].'); - HandlerResolver::resolve([ValidHandlerClass::class, 123]); /* @phpstan-ignore argument.type */ - } - - public function testThrowsForClosureInArrayHandler(): void - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Invalid array handler format. Expected [ClassName::class, \'methodName\'] or [$instance, \'methodName\'].'); - HandlerResolver::resolve([static fn () => null, 'method']); - } - - public function testThrowsForNonExistentClassInArrayHandler(): void - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Handler class "NonExistentClass" not found'); - HandlerResolver::resolve(['NonExistentClass', 'method']); - } - - public function testThrowsForNonExistentMethodInArrayHandler(): void - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Handler method "nonExistentMethod" not found in class'); - HandlerResolver::resolve([ValidHandlerClass::class, 'nonExistentMethod']); - } - - public function testThrowsForNonExistentClassInStringHandler(): void - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Invalid handler format. Expected Closure, [ClassName::class, \'methodName\'] or InvokableClassName::class string.'); - HandlerResolver::resolve('NonExistentInvokableClass'); - } - - public function testThrowsForNonInvokableClassStringHandler(): void - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Invokable handler class "Mcp\Tests\Unit\Capability\Discovery\NonInvokableClass" must have a public "__invoke" method.'); - HandlerResolver::resolve(NonInvokableClass::class); - } - - public function testThrowsForProtectedMethodHandler(): void - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('must be public'); - HandlerResolver::resolve([ValidHandlerClass::class, 'protectedMethod']); - } - - public function testThrowsForPrivateMethodHandler(): void - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('must be public'); - HandlerResolver::resolve([ValidHandlerClass::class, 'privateMethod']); - } - - public function testThrowsForConstructorAsHandler(): void - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('cannot be a constructor or destructor'); - HandlerResolver::resolve([ValidHandlerClass::class, '__construct']); - } - - public function testThrowsForDestructorAsHandler(): void - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('cannot be a constructor or destructor'); - HandlerResolver::resolve([ValidHandlerClass::class, '__destruct']); - } - - public function testThrowsForAbstractMethodHandler(): void - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Handler method "Mcp\Tests\Unit\Capability\Discovery\AbstractHandlerClass::abstractMethod" must not be abstract.'); - HandlerResolver::resolve([AbstractHandlerClass::class, 'abstractMethod']); - } - - public function testResolvesClosuresWithDifferentSignatures(): void - { - $noParams = static function () { - return 'test'; - }; - $withParams = static function (int $a, string $b = 'default') { - return $a.$b; - }; - $variadic = static function (...$args) { - return $args; - }; - $this->assertInstanceOf(\ReflectionFunction::class, HandlerResolver::resolve($noParams)); - $this->assertInstanceOf(\ReflectionFunction::class, HandlerResolver::resolve($withParams)); - $this->assertInstanceOf(\ReflectionFunction::class, HandlerResolver::resolve($variadic)); - $this->assertEquals(0, HandlerResolver::resolve($noParams)->getNumberOfParameters()); - $this->assertEquals(2, HandlerResolver::resolve($withParams)->getNumberOfParameters()); - $this->assertTrue(HandlerResolver::resolve($variadic)->isVariadic()); - } - - public function testDistinguishesBetweenClosuresAndCallableArrays(): void - { - $closure = static function () { - return 'closure'; - }; - $array = [ValidHandlerClass::class, 'publicMethod']; - $string = ValidInvokableClass::class; - $this->assertInstanceOf(\ReflectionFunction::class, HandlerResolver::resolve($closure)); - $this->assertInstanceOf(\ReflectionMethod::class, HandlerResolver::resolve($array)); - $this->assertInstanceOf(\ReflectionMethod::class, HandlerResolver::resolve($string)); - } -} - -// Helper classes -class ValidHandlerClass -{ - public function publicMethod(): void - { - } - - protected function protectedMethod(): void - { - } - - private function privateMethod(): void /* @phpstan-ignore method.unused */ - { - } - - public static function staticMethod(): void - { - } - - public function __construct() - { - } - - public function __destruct() - { - } -} -class ValidInvokableClass -{ - public function __invoke(): void - { - } -} -class NonInvokableClass -{ -} -abstract class AbstractHandlerClass -{ - abstract public function abstractMethod(): void; -} diff --git a/tests/Unit/Capability/Discovery/SchemaGeneratorFixture.php b/tests/Unit/Capability/Discovery/SchemaGeneratorFixture.php deleted file mode 100644 index 548c5d9b..00000000 --- a/tests/Unit/Capability/Discovery/SchemaGeneratorFixture.php +++ /dev/null @@ -1,492 +0,0 @@ - 'object', - 'description' => 'Creates a custom filter with complete definition', - 'properties' => [ - 'field' => ['type' => 'string', 'enum' => ['name', 'date', 'status']], - 'operator' => ['type' => 'string', 'enum' => ['eq', 'gt', 'lt', 'contains']], - 'value' => ['description' => 'Value to filter by, type depends on field and operator'], - ], - 'required' => ['field', 'operator', 'value'], - 'if' => [ - 'properties' => ['field' => ['const' => 'date']], - ], - 'then' => [ - 'properties' => ['value' => ['type' => 'string', 'format' => 'date']], - ], - ])] - public function methodLevelCompleteDefinition(string $field, string $operator, mixed $value): array - { - return compact('field', 'operator', 'value'); - } - - /** - * Method-level Schema defining properties. - */ - #[Schema( - description: 'Creates a new user with detailed information.', - properties: [ - 'username' => ['type' => 'string', 'minLength' => 3, 'pattern' => '^[a-zA-Z0-9_]+$'], - 'email' => ['type' => 'string', 'format' => 'email'], - 'age' => ['type' => 'integer', 'minimum' => 18, 'description' => 'Age in years.'], - 'isActive' => ['type' => 'boolean', 'default' => true], - ], - required: ['username', 'email'] - )] - public function methodLevelWithProperties(string $username, string $email, int $age, bool $isActive = true): array - { - return compact('username', 'email', 'age', 'isActive'); - } - - /** - * Method-level Schema for complex array argument. - */ - #[Schema( - properties: [ - 'profiles' => [ - 'type' => 'array', - 'description' => 'An array of user profiles to update.', - 'minItems' => 1, - 'items' => [ - 'type' => 'object', - 'properties' => [ - 'id' => ['type' => 'integer'], - 'data' => ['type' => 'object', 'additionalProperties' => true], - ], - 'required' => ['id', 'data'], - ], - ], - ], - required: ['profiles'] - )] - public function methodLevelArrayArgument(array $profiles): array - { - return ['updated_count' => \count($profiles)]; - } - - // ===== PARAMETER-LEVEL SCHEMA SCENARIOS ===== - - /** - * Parameter-level Schema attributes only. - */ - public function parameterLevelOnly( - #[Schema(description: 'Recipient ID', pattern: '^user_')] - string $recipientId, - #[Schema(maxLength: 1024)] - string $messageBody, - #[Schema(type: 'integer', enum: [1, 2, 5])] - int $priority = 1, - #[Schema( - type: 'object', - properties: [ - 'type' => ['type' => 'string', 'enum' => ['sms', 'email', 'push']], - 'deviceToken' => ['type' => 'string', 'description' => 'Required if type is push'], - ], - required: ['type'] - )] - ?array $notificationConfig = null, - ): array { - return compact('recipientId', 'messageBody', 'priority', 'notificationConfig'); - } - - /** - * Parameter-level Schema with string constraints. - */ - public function parameterStringConstraints( - #[Schema(format: 'email')] - string $email, - #[Schema(minLength: 8, pattern: '^(?=.*[A-Za-z])(?=.*\d)[A-Za-z\d]{8,}$')] - string $password, - string $regularString, - ): void { - } - - /** - * Parameter-level Schema with numeric constraints. - */ - public function parameterNumericConstraints( - #[Schema(minimum: 18, maximum: 120)] - int $age, - #[Schema(minimum: 0, maximum: 5, exclusiveMaximum: true)] - float $rating, - #[Schema(multipleOf: 10)] - int $count, - ): void { - } - - /** - * Parameter-level Schema with array constraints. - */ - public function parameterArrayConstraints( - #[Schema(type: 'array', items: ['type' => 'string'], minItems: 1, uniqueItems: true)] - array $tags, - #[Schema(type: 'array', items: ['type' => 'integer', 'minimum' => 0, 'maximum' => 100], minItems: 1, maxItems: 5)] - array $scores, - ): void { - } - - // ===== COMBINED SCENARIOS ===== - - /** - * Method-level + Parameter-level Schema combination. - * - * @param string $settingKey The key of the setting - * @param mixed $newValue The new value for the setting - */ - #[Schema( - properties: [ - 'settingKey' => ['type' => 'string', 'description' => 'The key of the setting.'], - 'newValue' => ['description' => 'The new value for the setting (any type).'], - ], - required: ['settingKey', 'newValue'] - )] - public function methodAndParameterLevel( - string $settingKey, - #[Schema(description: 'The specific new boolean value.', type: 'boolean')] - mixed $newValue, - ): array { - return compact('settingKey', 'newValue'); - } - - /** - * Type hints + DocBlock + Parameter-level Schema. - * - * @param string $username The user's name - * @param int $priority Task priority level - */ - public function typeHintDocBlockAndParameterSchema( - #[Schema(minLength: 3, pattern: '^[a-zA-Z0-9_]+$')] - string $username, - #[Schema(minimum: 1, maximum: 10)] - int $priority, - ): void { - } - - /** - * PHPStan/Psalm integer ranges should keep their base integer type. - * - * @param int<0, max> $offset Positive offset - * @param int $negative Negative value - * @param int<-5, 10> $bounded Bounded value - */ - public function integerRangeTypes( - #[Schema(minimum: 0)] - int $offset, - int $negative, - int $bounded, - ): void { - } - - // ===== ENUM SCENARIOS ===== - - /** - * Various enum parameter types. - * - * @param BackedStringEnum $stringEnum Backed string enum - * @param BackedIntEnum $intEnum Backed int enum - * @param UnitEnum $unitEnum Unit enum - */ - public function enumParameters( - BackedStringEnum $stringEnum, - BackedIntEnum $intEnum, - UnitEnum $unitEnum, - ?BackedStringEnum $nullableEnum = null, - BackedIntEnum $enumWithDefault = BackedIntEnum::First, - ): void { - } - - // ===== ARRAY TYPE SCENARIOS ===== - - /** - * Various array type scenarios. - * - * @param array $genericArray Generic array - * @param string[] $stringArray Array of strings - * @param int[] $intArray Array of integers - * @param array $mixedMap Mixed array map - * @param array{name: string, age: int} $objectLikeArray Object-like array - * @param array{user: array{id: int, name: string}, items: int[]} $nestedObjectArray Nested object array - */ - public function arrayTypeScenarios( - array $genericArray, - array $stringArray, - array $intArray, - array $mixedMap, - array $objectLikeArray, - array $nestedObjectArray, - ): void { - } - - /** - * Nullable typed arrays should still recover their element type. - * - * @param string[]|null $nullableStrings Nullable list of strings - * @param array|null $nullableInts Nullable list of integers - */ - public function nullableTypedArrays( - ?array $nullableStrings, - ?array $nullableInts = null, - ): void { - } - - // ===== NULLABLE AND OPTIONAL SCENARIOS ===== - - /** - * Nullable and optional parameter scenarios. - * - * @param string|null $nullableString Nullable string - * @param int|null $nullableInt Nullable integer - */ - public function nullableAndOptional( - ?string $nullableString, - ?int $nullableInt = null, - string $optionalString = 'default', - bool $optionalBool = true, - array $optionalArray = [], - ): void { - } - - // ===== UNION TYPE SCENARIOS ===== - - /** - * Union type parameters. - * - * @param string|int $stringOrInt String or integer - * @param bool|string|null $multiUnion Bool, string or null - */ - public function unionTypes( - string|int $stringOrInt, - bool|string|null $multiUnion, - ): void { - } - - // ===== VARIADIC SCENARIOS ===== - - /** - * Variadic parameter scenarios. - * - * @param string ...$items Variadic strings - */ - public function variadicStrings(string ...$items): void - { - } - - /** - * Variadic parameter without a type hint. - * - * @param mixed ...$values Variadic values - */ - public function untypedVariadic(...$values): void - { - } - - /** - * Variadic with Schema constraints. - * - * @param int ...$numbers Variadic integers - */ - public function variadicWithConstraints( - #[Schema(items: ['type' => 'integer', 'minimum' => 0])] - int ...$numbers, - ): void { - } - - // ===== MIXED TYPE SCENARIOS ===== - - /** - * Mixed type scenarios. - * - * @param mixed $anyValue Any value - * @param mixed $optionalAny Optional any value - */ - public function mixedTypes( - mixed $anyValue, - mixed $optionalAny = 'default', - ): void { - } - - // ===== COMPLEX NESTED SCENARIOS ===== - - /** - * Complex nested Schema constraints. - */ - #[McpTool( - outputSchema: [ - 'type' => 'object', - 'additionalProperties' => true, - ] - )] - public function complexNestedSchema( - #[Schema( - type: 'object', - properties: [ - 'customer' => [ - 'type' => 'object', - 'properties' => [ - 'id' => ['type' => 'string', 'pattern' => '^CUS-[0-9]{6}$'], - 'name' => ['type' => 'string', 'minLength' => 2], - 'email' => ['type' => 'string', 'format' => 'email'], - ], - 'required' => ['id', 'name'], - ], - 'items' => [ - 'type' => 'array', - 'minItems' => 1, - 'items' => [ - 'type' => 'object', - 'properties' => [ - 'product_id' => ['type' => 'string', 'pattern' => '^PRD-[0-9]{4}$'], - 'quantity' => ['type' => 'integer', 'minimum' => 1], - 'price' => ['type' => 'number', 'minimum' => 0], - ], - 'required' => ['product_id', 'quantity', 'price'], - ], - ], - 'metadata' => [ - 'type' => 'object', - 'additionalProperties' => true, - ], - ], - required: ['customer', 'items'] - )] - array $order, - ): array { - return ['order_id' => uniqid()]; - } - - // ===== TYPE PRECEDENCE SCENARIOS ===== - - /** - * Testing type precedence between PHP, DocBlock, and Schema. - * - * @param int $numericString DocBlock says integer despite string type hint - * @param string $stringWithConstraints String with Schema constraints - * @param array $arrayWithItems Array with Schema item overrides - */ - public function typePrecedenceTest( - string $numericString, - #[Schema(format: 'email', minLength: 5)] - string $stringWithConstraints, - #[Schema(items: ['type' => 'integer', 'minimum' => 1, 'maximum' => 100])] - array $arrayWithItems, - ): void { - } - - // ===== ERROR EDGE CASES ===== - - /** - * Method with no parameters but Schema description. - */ - #[Schema(description: 'Gets server status. Takes no arguments.', properties: [])] - #[McpTool( - outputSchema: [ - 'type' => 'object', - 'additionalProperties' => true, - ] - )] - public function noParamsWithSchema(): array - { - return ['status' => 'OK']; - } - - /** - * Parameter with Schema but inferred type. - */ - public function parameterSchemaInferredType( - #[Schema(description: 'Some parameter', minLength: 3)] - $inferredParam, - ): void { - } - - public function withParameterNamedSession(string $_session): void - { - } - - public function withParameterNamedSessionWithWeirdCase(string $_sesSion): void - { - } - - public function withParameterNamedRequest(string $_request): void - { - } - - // ===== OUTPUT SCHEMA FIXTURES ===== - #[McpTool( - outputSchema: [ - 'type' => 'object', - 'properties' => [ - 'message' => ['type' => 'string'], - ], - 'required' => ['message'], - 'description' => 'The result of the operation', - ] - )] - public function returnWithExplicitOutputSchema(): array - { - return ['message' => 'result']; - } -} diff --git a/tests/Unit/Capability/Discovery/SchemaGeneratorTest.php b/tests/Unit/Capability/Discovery/SchemaGeneratorTest.php deleted file mode 100644 index b5dbb09b..00000000 --- a/tests/Unit/Capability/Discovery/SchemaGeneratorTest.php +++ /dev/null @@ -1,421 +0,0 @@ -schemaGenerator = new SchemaGenerator(new DocBlockParser()); - } - - public function testGeneratesEmptyPropertiesObjectForMethodWithNoParameters(): void - { - $method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'noParams'); - $schema = $this->schemaGenerator->generate($method); - $this->assertEquals([ - 'type' => 'object', - 'properties' => new \stdClass(), - ], $schema); - $this->assertArrayNotHasKey('required', $schema); - } - - public function testInfersBasicTypesFromPhpTypeHints(): void - { - $method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'typeHintsOnly'); - $schema = $this->schemaGenerator->generate($method); - $this->assertEquals(['type' => 'string'], $schema['properties']['name']); - $this->assertEquals(['type' => 'integer'], $schema['properties']['age']); - $this->assertEquals(['type' => 'boolean'], $schema['properties']['active']); - $this->assertEquals(['type' => 'array', 'items' => new \stdClass()], $schema['properties']['tags']); - $this->assertEquals(['type' => ['null', 'object'], 'default' => null], $schema['properties']['config']); - $this->assertEqualsCanonicalizing(['name', 'age', 'active', 'tags'], $schema['required']); - } - - public function testInfersTypesAndDescriptionsFromDocBlockTags(): void - { - $method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'docBlockOnly'); - $schema = $this->schemaGenerator->generate($method); - $this->assertEquals(['type' => 'string', 'description' => 'The username'], $schema['properties']['username']); - $this->assertEquals(['type' => 'integer', 'description' => 'Number of items'], $schema['properties']['count']); - $this->assertEquals(['type' => 'boolean', 'description' => 'Whether enabled'], $schema['properties']['enabled']); - $this->assertEquals(['type' => 'array', 'description' => 'Some data', 'items' => new \stdClass()], $schema['properties']['data']); - $this->assertEqualsCanonicalizing(['username', 'count', 'enabled', 'data'], $schema['required']); - } - - public function testUsesPhpTypeHintsForTypeAndDocBlockForDescriptions(): void - { - $method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'typeHintsWithDocBlock'); - $schema = $this->schemaGenerator->generate($method); - $this->assertEquals(['type' => 'string', 'description' => 'User email address'], $schema['properties']['email']); - $this->assertEquals(['type' => 'integer', 'description' => 'User score'], $schema['properties']['score']); - $this->assertEquals(['type' => 'boolean', 'description' => 'Whether user is verified'], $schema['properties']['verified']); - $this->assertEqualsCanonicalizing(['email', 'score', 'verified'], $schema['required']); - } - - public function testUsesCompleteSchemaDefinitionFromMethodLevelSchemaAttribute(): void - { - $method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'methodLevelCompleteDefinition'); - $schema = $this->schemaGenerator->generate($method); - $this->assertEquals([ - 'type' => 'object', - 'description' => 'Creates a custom filter with complete definition', - 'properties' => [ - 'field' => ['type' => 'string', 'enum' => ['name', 'date', 'status']], - 'operator' => ['type' => 'string', 'enum' => ['eq', 'gt', 'lt', 'contains']], - 'value' => ['description' => 'Value to filter by, type depends on field and operator'], - ], - 'required' => ['field', 'operator', 'value'], - 'if' => [ - 'properties' => ['field' => ['const' => 'date']], - ], - 'then' => [ - 'properties' => ['value' => ['type' => 'string', 'format' => 'date']], - ], - ], $schema); - } - - public function testGeneratesSchemaFromMethodLevelSchemaAttributeWithProperties(): void - { - $method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'methodLevelWithProperties'); - $schema = $this->schemaGenerator->generate($method); - $this->assertEquals('Creates a new user with detailed information.', $schema['description']); - $this->assertEquals(['type' => 'string', 'minLength' => 3, 'pattern' => '^[a-zA-Z0-9_]+$'], $schema['properties']['username']); - $this->assertEquals(['type' => 'string', 'format' => 'email'], $schema['properties']['email']); - $this->assertEquals(['type' => 'integer', 'minimum' => 18, 'description' => 'Age in years.'], $schema['properties']['age']); - $this->assertEquals(['type' => 'boolean', 'default' => true], $schema['properties']['isActive']); - $this->assertEqualsCanonicalizing(['age', 'username', 'email'], $schema['required']); - } - - public function testGeneratesSchemaForSingleArrayArgumentFromMethodLevelSchemaAttribute(): void - { - $method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'methodLevelArrayArgument'); - $schema = $this->schemaGenerator->generate($method); - $this->assertEquals([ - 'type' => 'array', - 'description' => 'An array of user profiles to update.', - 'minItems' => 1, - 'items' => [ - 'type' => 'object', - 'properties' => [ - 'id' => ['type' => 'integer'], - 'data' => ['type' => 'object', 'additionalProperties' => true], - ], - 'required' => ['id', 'data'], - ], - ], $schema['properties']['profiles']); - $this->assertEquals(['profiles'], $schema['required']); - } - - public function testGeneratesSchemaFromIndividualParameterLevelSchemaAttributes(): void - { - $method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'parameterLevelOnly'); - $schema = $this->schemaGenerator->generate($method); - $this->assertEquals(['description' => 'Recipient ID', 'pattern' => '^user_', 'type' => 'string'], $schema['properties']['recipientId']); - $this->assertEquals(['maxLength' => 1024, 'type' => 'string'], $schema['properties']['messageBody']); - $this->assertEquals(['type' => 'integer', 'enum' => [1, 2, 5], 'default' => 1], $schema['properties']['priority']); - $this->assertEquals([ - 'type' => 'object', - 'properties' => [ - 'type' => ['type' => 'string', 'enum' => ['sms', 'email', 'push']], - 'deviceToken' => ['type' => 'string', 'description' => 'Required if type is push'], - ], - 'required' => ['type'], - 'default' => null, - ], $schema['properties']['notificationConfig']); - $this->assertEqualsCanonicalizing(['recipientId', 'messageBody'], $schema['required']); - } - - public function testAppliesStringConstraintsFromParameterLevelSchemaAttributes(): void - { - $method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'parameterStringConstraints'); - $schema = $this->schemaGenerator->generate($method); - $this->assertEquals(['format' => 'email', 'type' => 'string'], $schema['properties']['email']); - $this->assertEquals(['minLength' => 8, 'pattern' => '^(?=.*[A-Za-z])(?=.*\d)[A-Za-z\d]{8,}$', 'type' => 'string'], $schema['properties']['password']); - $this->assertEquals(['type' => 'string'], $schema['properties']['regularString']); - $this->assertEqualsCanonicalizing(['email', 'password', 'regularString'], $schema['required']); - } - - public function testAppliesNumericConstraintsFromParameterLevelSchemaAttributes(): void - { - $method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'parameterNumericConstraints'); - $schema = $this->schemaGenerator->generate($method); - $this->assertEquals(['minimum' => 18, 'maximum' => 120, 'type' => 'integer'], $schema['properties']['age']); - $this->assertEquals(['minimum' => 0, 'maximum' => 5, 'exclusiveMaximum' => true, 'type' => 'number'], $schema['properties']['rating']); - $this->assertEquals(['multipleOf' => 10, 'type' => 'integer'], $schema['properties']['count']); - $this->assertEqualsCanonicalizing(['age', 'rating', 'count'], $schema['required']); - } - - public function testAppliesArrayConstraintsFromParameterLevelSchemaAttributes(): void - { - $method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'parameterArrayConstraints'); - $schema = $this->schemaGenerator->generate($method); - $this->assertEquals(['type' => 'array', 'items' => ['type' => 'string'], 'minItems' => 1, 'uniqueItems' => true], $schema['properties']['tags']); - $this->assertEquals(['type' => 'array', 'items' => ['type' => 'integer', 'minimum' => 0, 'maximum' => 100], 'minItems' => 1, 'maxItems' => 5], $schema['properties']['scores']); - $this->assertEqualsCanonicalizing(['tags', 'scores'], $schema['required']); - } - - public function testMergesMethodLevelAndParameterLevelSchemaAttributes(): void - { - $method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'methodAndParameterLevel'); - $schema = $this->schemaGenerator->generate($method); - $this->assertEquals(['type' => 'string', 'description' => 'The key of the setting.'], $schema['properties']['settingKey']); - $this->assertEquals(['description' => 'The specific new boolean value.', 'type' => 'boolean'], $schema['properties']['newValue']); - $this->assertEqualsCanonicalizing(['settingKey', 'newValue'], $schema['required']); - } - - public function testCombinesPhpTypeHintsDocBlockDescriptionsAndParameterLevelSchemaConstraints(): void - { - $method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'typeHintDocBlockAndParameterSchema'); - $schema = $this->schemaGenerator->generate($method); - $this->assertEquals(['minLength' => 3, 'pattern' => '^[a-zA-Z0-9_]+$', 'type' => 'string', 'description' => "The user's name"], $schema['properties']['username']); - $this->assertEquals(['minimum' => 1, 'maximum' => 10, 'type' => 'integer', 'description' => 'Task priority level'], $schema['properties']['priority']); - $this->assertEqualsCanonicalizing(['username', 'priority'], $schema['required']); - } - - public function testMapsPhpStanAndPsalmIntegerRangesToBaseIntegerType(): void - { - $method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'integerRangeTypes'); - $schema = $this->schemaGenerator->generate($method); - - $this->assertEquals(['type' => 'integer', 'description' => 'Positive offset', 'minimum' => 0], $schema['properties']['offset']); - $this->assertEquals(['type' => 'integer', 'description' => 'Negative value'], $schema['properties']['negative']); - $this->assertEquals(['type' => 'integer', 'description' => 'Bounded value'], $schema['properties']['bounded']); - } - - public function testGeneratesCorrectSchemaForEnumParameters(): void - { - $method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'enumParameters'); - $schema = $this->schemaGenerator->generate($method); - $this->assertEquals(['type' => 'string', 'description' => 'Backed string enum', 'enum' => ['A', 'B']], $schema['properties']['stringEnum']); - $this->assertEquals(['type' => 'integer', 'description' => 'Backed int enum', 'enum' => [1, 2]], $schema['properties']['intEnum']); - $this->assertEquals(['type' => 'string', 'description' => 'Unit enum', 'enum' => ['Yes', 'No']], $schema['properties']['unitEnum']); - $this->assertEquals(['type' => ['string', 'null'], 'enum' => ['A', 'B', null], 'default' => null], $schema['properties']['nullableEnum']); - $this->assertEquals(['type' => 'integer', 'enum' => [1, 2], 'default' => 1], $schema['properties']['enumWithDefault']); - $this->assertEqualsCanonicalizing(['stringEnum', 'intEnum', 'unitEnum'], $schema['required']); - } - - public function testGeneratesCorrectSchemaForArrayTypeDeclarations(): void - { - $method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'arrayTypeScenarios'); - $schema = $this->schemaGenerator->generate($method); - $this->assertEquals(['type' => 'array', 'description' => 'Generic array', 'items' => new \stdClass()], $schema['properties']['genericArray']); - // An untyped array must still declare `items`, serialized as the empty schema `{}` - // (not `[]`) so strict clients accept it. - $this->assertSame('{}', json_encode($schema['properties']['genericArray']['items'])); - $this->assertEquals(['type' => 'array', 'description' => 'Array of strings', 'items' => ['type' => 'string']], $schema['properties']['stringArray']); - $this->assertEquals(['type' => 'array', 'description' => 'Array of integers', 'items' => ['type' => 'integer']], $schema['properties']['intArray']); - $this->assertEquals(['type' => 'array', 'description' => 'Mixed array map', 'items' => new \stdClass()], $schema['properties']['mixedMap']); - $this->assertArrayHasKey('type', $schema['properties']['objectLikeArray']); - $this->assertEquals('object', $schema['properties']['objectLikeArray']['type']); - $this->assertArrayHasKey('properties', $schema['properties']['objectLikeArray']); - $this->assertArrayHasKey('name', $schema['properties']['objectLikeArray']['properties']); - $this->assertArrayHasKey('age', $schema['properties']['objectLikeArray']['properties']); - $this->assertEqualsCanonicalizing(['genericArray', 'stringArray', 'intArray', 'mixedMap', 'objectLikeArray', 'nestedObjectArray'], $schema['required']); - } - - public function testRecoversItemsTypeForNullableTypedArrays(): void - { - $method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'nullableTypedArrays'); - $schema = $this->schemaGenerator->generate($method); - // A `|null` suffix must not erase the element type: `string[]|null` keeps `items: {type: string}`. - $this->assertEquals(['type' => ['array', 'null'], 'description' => 'Nullable list of strings', 'items' => ['type' => 'string']], $schema['properties']['nullableStrings']); - $this->assertEquals(['type' => ['array', 'null'], 'description' => 'Nullable list of integers', 'default' => null, 'items' => ['type' => 'integer']], $schema['properties']['nullableInts']); - $this->assertEqualsCanonicalizing(['nullableStrings'], $schema['required']); - } - - public function testHandlesNullableTypeHintsAndOptionalParameters(): void - { - $method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'nullableAndOptional'); - $schema = $this->schemaGenerator->generate($method); - $this->assertEquals(['type' => ['null', 'string'], 'description' => 'Nullable string'], $schema['properties']['nullableString']); - $this->assertEquals(['type' => ['null', 'integer'], 'description' => 'Nullable integer', 'default' => null], $schema['properties']['nullableInt']); - $this->assertEquals(['type' => 'string', 'default' => 'default'], $schema['properties']['optionalString']); - $this->assertEquals(['type' => 'boolean', 'default' => true], $schema['properties']['optionalBool']); - $this->assertEquals(['type' => 'array', 'default' => [], 'items' => new \stdClass()], $schema['properties']['optionalArray']); - $this->assertEqualsCanonicalizing(['nullableString'], $schema['required']); - } - - public function testGeneratesSchemaForPhpUnionTypes(): void - { - $method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'unionTypes'); - $schema = $this->schemaGenerator->generate($method); - $this->assertEquals(['type' => ['integer', 'string'], 'description' => 'String or integer'], $schema['properties']['stringOrInt']); - $this->assertEquals(['type' => ['null', 'boolean', 'string'], 'description' => 'Bool, string or null'], $schema['properties']['multiUnion']); - $this->assertEqualsCanonicalizing(['stringOrInt', 'multiUnion'], $schema['required']); - } - - public function testRepresentsVariadicStringParametersAsArrayOfStrings(): void - { - $method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'variadicStrings'); - $schema = $this->schemaGenerator->generate($method); - $this->assertEquals(['type' => 'array', 'description' => 'Variadic strings', 'items' => ['type' => 'string']], $schema['properties']['items']); - $this->assertArrayNotHasKey('required', $schema); - } - - public function testAppliesItemConstraintsToVariadicParameters(): void - { - $method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'variadicWithConstraints'); - $schema = $this->schemaGenerator->generate($method); - $this->assertEquals(['items' => ['type' => 'integer', 'minimum' => 0], 'type' => 'array', 'description' => 'Variadic integers'], $schema['properties']['numbers']); - $this->assertArrayNotHasKey('required', $schema); - } - - public function testUntypedVariadicStillDeclaresItems(): void - { - $method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'untypedVariadic'); - $schema = $this->schemaGenerator->generate($method); - $this->assertEquals(['type' => 'array', 'description' => 'Variadic values', 'items' => new \stdClass()], $schema['properties']['values']); - $this->assertSame('{}', json_encode($schema['properties']['values']['items'])); - } - - public function testHandlesMixedTypeHintsOmittingExplicitType(): void - { - $method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'mixedTypes'); - $schema = $this->schemaGenerator->generate($method); - $this->assertEquals(['description' => 'Any value'], $schema['properties']['anyValue']); - $this->assertEquals(['description' => 'Optional any value', 'default' => 'default'], $schema['properties']['optionalAny']); - $this->assertEqualsCanonicalizing(['anyValue'], $schema['required']); - } - - public function testGeneratesSchemaForComplexNestedObjectAndArrayStructures(): void - { - $method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'complexNestedSchema'); - $schema = $this->schemaGenerator->generate($method); - $this->assertEquals([ - 'type' => 'object', - 'properties' => [ - 'customer' => [ - 'type' => 'object', - 'properties' => [ - 'id' => ['type' => 'string', 'pattern' => '^CUS-[0-9]{6}$'], - 'name' => ['type' => 'string', 'minLength' => 2], - 'email' => ['type' => 'string', 'format' => 'email'], - ], - 'required' => ['id', 'name'], - ], - 'items' => [ - 'type' => 'array', - 'minItems' => 1, - 'items' => [ - 'type' => 'object', - 'properties' => [ - 'product_id' => ['type' => 'string', 'pattern' => '^PRD-[0-9]{4}$'], - 'quantity' => ['type' => 'integer', 'minimum' => 1], - 'price' => ['type' => 'number', 'minimum' => 0], - ], - 'required' => ['product_id', 'quantity', 'price'], - ], - ], - 'metadata' => [ - 'type' => 'object', - 'additionalProperties' => true, - ], - ], - 'required' => ['customer', 'items'], - ], $schema['properties']['order']); - $this->assertEquals(['order'], $schema['required']); - } - - public function testTypePrecedenceParameterSchemaOverridesDocBlockOverridesPhpTypeHint(): void - { - $method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'typePrecedenceTest'); - $schema = $this->schemaGenerator->generate($method); - $this->assertEquals(['type' => 'integer', 'description' => 'DocBlock says integer despite string type hint'], $schema['properties']['numericString']); - $this->assertEquals(['format' => 'email', 'minLength' => 5, 'type' => 'string', 'description' => 'String with Schema constraints'], $schema['properties']['stringWithConstraints']); - $this->assertEquals(['items' => ['type' => 'integer', 'minimum' => 1, 'maximum' => 100], 'type' => 'array', 'description' => 'Array with Schema item overrides'], $schema['properties']['arrayWithItems']); - $this->assertEqualsCanonicalizing(['numericString', 'stringWithConstraints', 'arrayWithItems'], $schema['required']); - } - - public function testGeneratesEmptyPropertiesObjectForMethodWithNoParametersEvenWithMethodLevelSchema(): void - { - $method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'noParamsWithSchema'); - $schema = $this->schemaGenerator->generate($method); - $this->assertEquals('Gets server status. Takes no arguments.', $schema['description']); - $this->assertInstanceOf(\stdClass::class, $schema['properties']); - $this->assertArrayNotHasKey('required', $schema); - } - - public function testInfersParameterTypeAsAnyIfOnlyConstraintsAreGiven(): void - { - $method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'parameterSchemaInferredType'); - $schema = $this->schemaGenerator->generate($method); - $this->assertEquals(['description' => 'Some parameter', 'minLength' => 3], $schema['properties']['inferredParam']); - $this->assertEquals(['inferredParam'], $schema['required']); - } - - public static function methodsWithForbiddenParameter(): array - { - return [ - ['withParameterNamedSession'], - ['withParameterNamedSessionWithWeirdCase'], - ['withParameterNamedRequest'], - ]; - } - - #[DataProvider('methodsWithForbiddenParameter')] - public function testGenerateWithForbiddenParameterNames(string $methodName): void - { - $method = new \ReflectionMethod(SchemaGeneratorFixture::class, $methodName); - $this->expectException(InvalidArgumentException::class); - $this->schemaGenerator->generate($method); - } - - public function testGenerateOutputSchemaReturnsNullForVoidReturnType(): void - { - $method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'noParams'); - $schema = $this->schemaGenerator->generateOutputSchema($method); - $this->assertNull($schema); - } - - public function testGenerateOutputSchemaWithReturnDescription(): void - { - $method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'returnWithExplicitOutputSchema'); - $schema = $this->schemaGenerator->generateOutputSchema($method); - $this->assertEquals([ - 'type' => 'object', - 'properties' => [ - 'message' => ['type' => 'string'], - ], - 'required' => ['message'], - 'description' => 'The result of the operation', - ], $schema); - } - - public function testGenerateOutputSchemaForArrayReturnType(): void - { - $method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'noParamsWithSchema'); - $schema = $this->schemaGenerator->generateOutputSchema($method); - $this->assertEquals([ - 'type' => 'object', - 'additionalProperties' => true, - ], $schema); - } - - public function testGenerateOutputSchemaForComplexNestedSchema(): void - { - $method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'complexNestedSchema'); - $schema = $this->schemaGenerator->generateOutputSchema($method); - $this->assertEquals([ - 'type' => 'object', - 'additionalProperties' => true, - ], $schema); - } -} diff --git a/tests/Unit/Capability/Discovery/SchemaValidatorTest.php b/tests/Unit/Capability/Discovery/SchemaValidatorTest.php deleted file mode 100644 index 9464dcac..00000000 --- a/tests/Unit/Capability/Discovery/SchemaValidatorTest.php +++ /dev/null @@ -1,508 +0,0 @@ -validator = new SchemaValidator(); - } - - // --- Basic Validation Tests --- - - public function testValidDataPassesValidation(): void - { - $schema = $this->getSimpleSchema(); - $data = $this->getValidData(); - - $errors = $this->validator->validateAgainstJsonSchema($data, $schema); - - $this->assertEmpty($errors); - } - - public function testInvalidTypeGeneratesTypeError(): void - { - $schema = $this->getSimpleSchema(); - $data = $this->getValidData(); - $data['age'] = 'thirty'; // Invalid type - - $errors = $this->validator->validateAgainstJsonSchema($data, $schema); - - $this->assertCount(1, $errors); - $this->assertEquals('/age', $errors[0]['pointer']); - $this->assertEquals('type', $errors[0]['keyword']); - $this->assertStringContainsString('Expected `integer`', $errors[0]['message']); - } - - public function testMissingRequiredPropertyGeneratesRequiredError(): void - { - $schema = $this->getSimpleSchema(); - $data = $this->getValidData(); - unset($data['name']); // Missing required - - $errors = $this->validator->validateAgainstJsonSchema($data, $schema); - $this->assertCount(1, $errors); - $this->assertEquals('required', $errors[0]['keyword']); - $this->assertStringContainsString('Missing required properties: `name`', $errors[0]['message']); - } - - public function testAdditionalPropertyGeneratesAdditionalPropertiesError(): void - { - $schema = $this->getSimpleSchema(); - $data = $this->getValidData(); - $data['extra'] = 'not allowed'; // Additional property - - $errors = $this->validator->validateAgainstJsonSchema($data, $schema); - $this->assertCount(1, $errors); - $this->assertEquals('/', $errors[0]['pointer']); // Error reported at the object root - $this->assertEquals('additionalProperties', $errors[0]['keyword']); - $this->assertStringContainsString('Additional object properties are not allowed: ["extra"]', $errors[0]['message']); - } - - // --- Keyword Constraint Tests --- - - public function testEnumConstraintViolation(): void - { - $schema = ['type' => 'string', 'enum' => ['A', 'B']]; - $data = 'C'; - - $errors = $this->validator->validateAgainstJsonSchema($data, $schema); - $this->assertCount(1, $errors); - $this->assertEquals('enum', $errors[0]['keyword']); - $this->assertStringContainsString('must be one of the allowed values: "A", "B"', $errors[0]['message']); - } - - public function testMinimumConstraintViolation(): void - { - $schema = ['type' => 'integer', 'minimum' => 10]; - $data = 5; - - $errors = $this->validator->validateAgainstJsonSchema($data, $schema); - $this->assertCount(1, $errors); - $this->assertEquals('minimum', $errors[0]['keyword']); - $this->assertStringContainsString('must be greater than or equal to 10', $errors[0]['message']); - } - - public function testMaxLengthConstraintViolation(): void - { - $schema = ['type' => 'string', 'maxLength' => 5]; - $data = 'toolong'; - - $errors = $this->validator->validateAgainstJsonSchema($data, $schema); - $this->assertCount(1, $errors); - $this->assertEquals('maxLength', $errors[0]['keyword']); - $this->assertStringContainsString('Maximum string length is 5, found 7', $errors[0]['message']); - } - - public function testPatternConstraintViolation(): void - { - $schema = ['type' => 'string', 'pattern' => '^[a-z]+$']; - $data = '123'; - - $errors = $this->validator->validateAgainstJsonSchema($data, $schema); - $this->assertCount(1, $errors); - $this->assertEquals('pattern', $errors[0]['keyword']); - $this->assertStringContainsString('does not match the required pattern: `^[a-z]+$`', $errors[0]['message']); - } - - public function testMinItemsConstraintViolation(): void - { - $schema = ['type' => 'array', 'minItems' => 2]; - $data = ['one']; - - $errors = $this->validator->validateAgainstJsonSchema($data, $schema); - $this->assertCount(1, $errors); - $this->assertEquals('minItems', $errors[0]['keyword']); - $this->assertStringContainsString('Array should have at least 2 items, 1 found', $errors[0]['message']); - } - - public function testUniqueItemsConstraintViolation(): void - { - $schema = ['type' => 'array', 'uniqueItems' => true]; - $data = ['a', 'b', 'a']; - - $errors = $this->validator->validateAgainstJsonSchema($data, $schema); - $this->assertCount(1, $errors); - $this->assertEquals('uniqueItems', $errors[0]['keyword']); - $this->assertStringContainsString('Array must have unique items', $errors[0]['message']); - } - - // --- Nested Structures and Pointers --- - public function testNestedObjectValidationErrorPointer(): void - { - $schema = [ - 'type' => 'object', - 'properties' => [ - 'user' => [ - 'type' => 'object', - 'properties' => ['id' => ['type' => 'integer']], - 'required' => ['id'], - ], - ], - 'required' => ['user'], - ]; - $data = ['user' => ['id' => 'abc']]; // Invalid nested type - - $errors = $this->validator->validateAgainstJsonSchema($data, $schema); - $this->assertCount(1, $errors); - $this->assertEquals('/user/id', $errors[0]['pointer']); - } - - public function testArrayItemValidationErrorPointer(): void - { - $schema = [ - 'type' => 'array', - 'items' => ['type' => 'integer'], - ]; - $data = [1, 2, 'three', 4]; // Invalid item type - - $errors = $this->validator->validateAgainstJsonSchema($data, $schema); - $this->assertCount(1, $errors); - $this->assertEquals('/2', $errors[0]['pointer']); // Pointer to the index of the invalid item - } - - // --- Data Conversion Tests --- - public function testValidatesDataPassedAsStdClassObject(): void - { - $schema = $this->getSimpleSchema(); - $dataObj = json_decode(json_encode($this->getValidData())); // Convert to stdClass - - $errors = $this->validator->validateAgainstJsonSchema($dataObj, $schema); - $this->assertEmpty($errors); - } - - public function testValidatesDataWithNestedAssociativeArraysCorrectly(): void - { - $schema = [ - 'type' => 'object', - 'properties' => [ - 'nested' => [ - 'type' => 'object', - 'properties' => ['key' => ['type' => 'string']], - 'required' => ['key'], - ], - ], - 'required' => ['nested'], - ]; - $data = ['nested' => ['key' => 'value']]; // Nested assoc array - - $errors = $this->validator->validateAgainstJsonSchema($data, $schema); - $this->assertEmpty($errors); - } - - // --- Edge Cases --- - public function testHandlesInvalidSchemaStructureGracefully(): void - { - $schema = ['type' => 'object', 'properties' => ['name' => ['type' => 123]]]; // Invalid type value - $data = ['name' => 'test']; - - $errors = $this->validator->validateAgainstJsonSchema($data, $schema); - $this->assertCount(1, $errors); - $this->assertEquals('internal', $errors[0]['keyword']); - $this->assertStringContainsString('Schema validation process failed', $errors[0]['message']); - } - - public function testHandlesEmptyDataObjectAgainstSchemaRequiringProperties(): void - { - $schema = $this->getSimpleSchema(); // Requires name, age etc. - $data = []; // Empty data - - $errors = $this->validator->validateAgainstJsonSchema($data, $schema); - - $this->assertNotEmpty($errors); - $this->assertEquals('required', $errors[0]['keyword']); - } - - public function testHandlesEmptySchemaAllowsAnything(): void - { - $schema = []; // Empty schema object/array implies no constraints - $data = ['anything' => [1, 2], 'goes' => true]; - - $errors = $this->validator->validateAgainstJsonSchema($data, $schema); - - $this->assertNotEmpty($errors); - $this->assertEquals('internal', $errors[0]['keyword']); - $this->assertStringContainsString('Invalid schema', $errors[0]['message']); - } - - public function testValidatesSchemaWithStringFormatConstraintsFromSchemaAttribute(): void - { - $emailSchema = (new Schema(format: 'email'))->toArray(); - - // Valid email - $validErrors = $this->validator->validateAgainstJsonSchema('user@example.com', $emailSchema); - $this->assertEmpty($validErrors); - - // Invalid email - $invalidErrors = $this->validator->validateAgainstJsonSchema('not-an-email', $emailSchema); - $this->assertNotEmpty($invalidErrors); - $this->assertEquals('format', $invalidErrors[0]['keyword']); - $this->assertStringContainsString('email', $invalidErrors[0]['message']); - } - - public function testValidatesSchemaWithStringLengthConstraintsFromSchemaAttribute(): void - { - $passwordSchema = (new Schema(minLength: 8, pattern: '^(?=.*[A-Za-z])(?=.*\d)[A-Za-z\d]{8,}$'))->toArray(); - - // Valid password (meets length and pattern) - $validErrors = $this->validator->validateAgainstJsonSchema('Password123', $passwordSchema); - $this->assertEmpty($validErrors); - - // Invalid - too short - $shortErrors = $this->validator->validateAgainstJsonSchema('Pass1', $passwordSchema); - $this->assertNotEmpty($shortErrors); - $this->assertEquals('minLength', $shortErrors[0]['keyword']); - - // Invalid - no digit - $noDigitErrors = $this->validator->validateAgainstJsonSchema('PasswordXYZ', $passwordSchema); - $this->assertNotEmpty($noDigitErrors); - $this->assertEquals('pattern', $noDigitErrors[0]['keyword']); - } - - public function testValidatesSchemaWithNumericConstraintsFromSchemaAttribute(): void - { - $ageSchema = (new Schema(minimum: 18, maximum: 120))->toArray(); - - // Valid age - $validErrors = $this->validator->validateAgainstJsonSchema(25, $ageSchema); - $this->assertEmpty($validErrors); - - // Invalid - too low - $tooLowErrors = $this->validator->validateAgainstJsonSchema(15, $ageSchema); - $this->assertNotEmpty($tooLowErrors); - $this->assertEquals('minimum', $tooLowErrors[0]['keyword']); - - // Invalid - too high - $tooHighErrors = $this->validator->validateAgainstJsonSchema(150, $ageSchema); - $this->assertNotEmpty($tooHighErrors); - $this->assertEquals('maximum', $tooHighErrors[0]['keyword']); - } - - public function testValidatesSchemaWithArrayConstraintsFromSchemaAttribute(): void - { - $tagsSchema = (new Schema(uniqueItems: true, minItems: 2))->toArray(); - - // Valid tags array - $validErrors = $this->validator->validateAgainstJsonSchema(['php', 'javascript', 'python'], $tagsSchema); - $this->assertEmpty($validErrors); - - // Invalid - duplicate items - $duplicateErrors = $this->validator->validateAgainstJsonSchema(['php', 'php', 'javascript'], $tagsSchema); - $this->assertNotEmpty($duplicateErrors); - $this->assertEquals('uniqueItems', $duplicateErrors[0]['keyword']); - - // Invalid - too few items - $tooFewErrors = $this->validator->validateAgainstJsonSchema(['php'], $tagsSchema); - $this->assertNotEmpty($tooFewErrors); - $this->assertEquals('minItems', $tooFewErrors[0]['keyword']); - } - - public function testValidatesSchemaWithObjectConstraintsFromSchemaAttribute(): void - { - $userSchema = (new Schema( - properties: [ - 'name' => ['type' => 'string', 'minLength' => 2], - 'email' => ['type' => 'string', 'format' => 'email'], - 'age' => ['type' => 'integer', 'minimum' => 18], - ], - required: ['name', 'email'] - ))->toArray(); - - // Valid user object - $validUser = [ - 'name' => 'John', - 'email' => 'john@example.com', - 'age' => 25, - ]; - $validErrors = $this->validator->validateAgainstJsonSchema($validUser, $userSchema); - $this->assertEmpty($validErrors); - - // Invalid - missing required email - $missingEmailUser = [ - 'name' => 'John', - 'age' => 25, - ]; - $missingErrors = $this->validator->validateAgainstJsonSchema($missingEmailUser, $userSchema); - $this->assertNotEmpty($missingErrors); - $this->assertEquals('required', $missingErrors[0]['keyword']); - - // Invalid - name too short - $shortNameUser = [ - 'name' => 'J', - 'email' => 'john@example.com', - 'age' => 25, - ]; - $nameErrors = $this->validator->validateAgainstJsonSchema($shortNameUser, $userSchema); - $this->assertNotEmpty($nameErrors); - $this->assertEquals('minLength', $nameErrors[0]['keyword']); - - // Invalid - age too low - $youngUser = [ - 'name' => 'John', - 'email' => 'john@example.com', - 'age' => 15, - ]; - $ageErrors = $this->validator->validateAgainstJsonSchema($youngUser, $userSchema); - $this->assertNotEmpty($ageErrors); - $this->assertEquals('minimum', $ageErrors[0]['keyword']); - } - - public function testValidatesSchemaWithNestedConstraintsFromSchemaAttribute(): void - { - $orderSchema = (new Schema( - properties: [ - 'customer' => [ - 'type' => 'object', - 'properties' => [ - 'id' => ['type' => 'string', 'pattern' => '^CUS-[0-9]{6}$'], - 'name' => ['type' => 'string', 'minLength' => 2], - ], - ], - 'items' => [ - 'type' => 'array', - 'minItems' => 1, - 'items' => [ - 'type' => 'object', - 'properties' => [ - 'product_id' => ['type' => 'string', 'pattern' => '^PRD-[0-9]{4}$'], - 'quantity' => ['type' => 'integer', 'minimum' => 1], - ], - 'required' => ['product_id', 'quantity'], - ], - ], - ], - required: ['customer', 'items'] - ))->toArray(); - - // Valid order - $validOrder = [ - 'customer' => [ - 'id' => 'CUS-123456', - 'name' => 'John', - ], - 'items' => [ - [ - 'product_id' => 'PRD-1234', - 'quantity' => 2, - ], - ], - ]; - $validErrors = $this->validator->validateAgainstJsonSchema($validOrder, $orderSchema); - $this->assertEmpty($validErrors); - - // Invalid - bad customer ID format - $badCustomerIdOrder = [ - 'customer' => [ - 'id' => 'CUST-123', // Wrong format - 'name' => 'John', - ], - 'items' => [ - [ - 'product_id' => 'PRD-1234', - 'quantity' => 2, - ], - ], - ]; - $customerIdErrors = $this->validator->validateAgainstJsonSchema($badCustomerIdOrder, $orderSchema); - $this->assertNotEmpty($customerIdErrors); - $this->assertEquals('pattern', $customerIdErrors[0]['keyword']); - - // Invalid - empty items array - $emptyItemsOrder = [ - 'customer' => [ - 'id' => 'CUS-123456', - 'name' => 'John', - ], - 'items' => [], - ]; - $emptyItemsErrors = $this->validator->validateAgainstJsonSchema($emptyItemsOrder, $orderSchema); - $this->assertNotEmpty($emptyItemsErrors); - $this->assertEquals('minItems', $emptyItemsErrors[0]['keyword']); - - // Invalid - missing required property in items - $missingProductIdOrder = [ - 'customer' => [ - 'id' => 'CUS-123456', - 'name' => 'John', - ], - 'items' => [ - [ - // Missing product_id - 'quantity' => 2, - ], - ], - ]; - $missingProductIdErrors = $this->validator->validateAgainstJsonSchema($missingProductIdOrder, $orderSchema); - $this->assertNotEmpty($missingProductIdErrors); - $this->assertEquals('required', $missingProductIdErrors[0]['keyword']); - } - - /** - * @return array{ - * type: 'object', - * properties: array>, - * required: string[], - * additionalProperties: false, - * } - */ - private function getSimpleSchema(): array - { - return [ - 'type' => 'object', - 'properties' => [ - 'name' => ['type' => 'string', 'description' => 'The name'], - 'age' => ['type' => 'integer', 'minimum' => 0], - 'active' => ['type' => 'boolean'], - 'score' => ['type' => 'number'], - 'items' => ['type' => 'array', 'items' => ['type' => 'string']], - 'status' => ['enum' => [null, 'active', 'inactive']], - 'nullableValue' => ['type' => ['string', 'null']], - 'optionalValue' => ['type' => 'string'], - ], - 'required' => ['name', 'age', 'active', 'score', 'items', 'nullableValue'], - 'additionalProperties' => false, - ]; - } - - /** - * @return array{ - * name: string, - * age: int, - * active: bool, - * score: float, - * items: string[], - * status: 'active'|'inactive'|null, - * nullableValue: null, - * optionalValue: string - * } - */ - private function getValidData(): array - { - return [ - 'name' => 'Tester', - 'age' => 30, - 'active' => true, - 'score' => 99.5, - 'items' => ['a', 'b'], - 'status' => null, - 'nullableValue' => null, - 'optionalValue' => 'present', - ]; - } -} diff --git a/tests/Unit/Capability/Formatter/PromptResultFormatterTest.php b/tests/Unit/Capability/Formatter/PromptResultFormatterTest.php deleted file mode 100644 index 52bb1767..00000000 --- a/tests/Unit/Capability/Formatter/PromptResultFormatterTest.php +++ /dev/null @@ -1,248 +0,0 @@ -format($message); - $this->assertCount(1, $result); - $this->assertSame($message, $result[0]); - } - - public function testFormatPromptMessageWithResourceLinkContent(): void - { - $message = new PromptMessage(Role::User, new ResourceLink('file:///a.png', 'a.png')); - $result = (new PromptResultFormatter())->format($message); - $this->assertCount(1, $result); - $this->assertSame($message, $result[0]); - } - - public function testFormatRoleContentArrayWithResourceLinkContent(): void - { - $result = (new PromptResultFormatter())->format([ - [ - 'role' => 'user', - 'content' => ['type' => 'resource_link', 'uri' => 'file:///a.png', 'name' => 'a.png'], - ], - ]); - $this->assertCount(1, $result); - $this->assertSame(Role::User, $result[0]->role); - $this->assertInstanceOf(ResourceLink::class, $result[0]->content); - $this->assertSame('file:///a.png', $result[0]->content->uri); - $this->assertSame('a.png', $result[0]->content->name); - } - - public function testFormatTypedResourceLinkContentPreservesOptionalFields(): void - { - $result = (new PromptResultFormatter())->format([ - [ - 'role' => 'user', - 'content' => [ - 'type' => 'resource_link', - 'uri' => 'file:///a.png', - 'name' => 'a.png', - 'title' => 'A picture', - 'description' => 'The first picture', - 'mimeType' => 'image/png', - 'size' => 1024, - 'annotations' => ['audience' => ['user'], 'priority' => 0.5], - '_meta' => ['origin' => 'test'], - ], - ], - ]); - - $content = $result[0]->content; - $this->assertInstanceOf(ResourceLink::class, $content); - $this->assertSame('file:///a.png', $content->uri); - $this->assertSame('a.png', $content->name); - $this->assertSame('A picture', $content->title); - $this->assertSame('The first picture', $content->description); - $this->assertSame('image/png', $content->mimeType); - $this->assertSame(1024, $content->size); - $this->assertNotNull($content->annotations); - $this->assertSame([Role::User], $content->annotations->audience); - $this->assertSame(0.5, $content->annotations->priority); - $this->assertSame(['origin' => 'test'], $content->meta); - } - - public function testFormatUserAssistantShorthand(): void - { - $result = (new PromptResultFormatter())->format([ - 'user' => 'Hello', - 'assistant' => 'Hi there', - ]); - $this->assertCount(2, $result); - $this->assertSame(Role::User, $result[0]->role); - $this->assertSame(Role::Assistant, $result[1]->role); - } - - public function testFormatRoleContentArray(): void - { - $result = (new PromptResultFormatter())->format([ - ['role' => 'user', 'content' => 'Hello'], - ]); - $this->assertCount(1, $result); - $this->assertSame(Role::User, $result[0]->role); - } - - public function testFormatTypedTextContentPreservesAnnotations(): void - { - $result = (new PromptResultFormatter())->format([ - [ - 'role' => 'user', - 'content' => [ - 'type' => 'text', - 'text' => 'Hello', - 'annotations' => ['audience' => ['user'], 'priority' => 0.5], - ], - ], - ]); - - $content = $result[0]->content; - $this->assertInstanceOf(TextContent::class, $content); - $this->assertSame('Hello', $content->text); - $this->assertNotNull($content->annotations); - $this->assertSame([Role::User], $content->annotations->audience); - $this->assertSame(0.5, $content->annotations->priority); - } - - public function testFormatTypedImageContentPreservesAnnotations(): void - { - $result = (new PromptResultFormatter())->format([ - [ - 'role' => 'user', - 'content' => [ - 'type' => 'image', - 'data' => base64_encode('binary'), - 'mimeType' => 'image/png', - 'annotations' => ['audience' => ['assistant']], - ], - ], - ]); - - $content = $result[0]->content; - $this->assertInstanceOf(ImageContent::class, $content); - $this->assertSame('image/png', $content->mimeType); - $this->assertNotNull($content->annotations); - $this->assertSame([Role::Assistant], $content->annotations->audience); - } - - public function testFormatTypedAudioContentPreservesAnnotations(): void - { - $result = (new PromptResultFormatter())->format([ - [ - 'role' => 'user', - 'content' => [ - 'type' => 'audio', - 'data' => base64_encode('binary'), - 'mimeType' => 'audio/mpeg', - 'annotations' => ['priority' => 1.0], - ], - ], - ]); - - $content = $result[0]->content; - $this->assertInstanceOf(AudioContent::class, $content); - $this->assertNotNull($content->annotations); - $this->assertSame(1.0, $content->annotations->priority); - } - - public function testFormatTypedResourceContentPreservesOptionalFields(): void - { - $result = (new PromptResultFormatter())->format([ - [ - 'role' => 'user', - 'content' => [ - 'type' => 'resource', - 'resource' => [ - 'uri' => 'file://data.json', - 'mimeType' => 'application/json', - 'text' => '{"key": "value"}', - '_meta' => ['origin' => 'test'], - ], - 'annotations' => ['audience' => ['user']], - ], - ], - ]); - - $content = $result[0]->content; - $this->assertInstanceOf(EmbeddedResource::class, $content); - $this->assertSame('application/json', $content->resource->mimeType); - $this->assertSame(['origin' => 'test'], $content->resource->meta); - $this->assertNotNull($content->annotations); - $this->assertSame([Role::User], $content->annotations->audience); - } - - public function testFormatTypedTextResourceContentDefaultsMimeType(): void - { - $result = (new PromptResultFormatter())->format([ - [ - 'role' => 'user', - 'content' => [ - 'type' => 'resource', - 'resource' => ['uri' => 'file://a.txt', 'text' => 'plain'], - ], - ], - ]); - - $this->assertSame('text/plain', $result[0]->content->resource->mimeType); - } - - public function testFormatTypedBlobResourceContentDefaultsMimeType(): void - { - $result = (new PromptResultFormatter())->format([ - [ - 'role' => 'user', - 'content' => [ - 'type' => 'resource', - 'resource' => ['uri' => 'file://a.bin', 'blob' => base64_encode('binary')], - ], - ], - ]); - - $this->assertSame('application/octet-stream', $result[0]->content->resource->mimeType); - } - - public function testFormatTypedContentRejectsInvalidDataWithIndexContext(): void - { - $this->expectException(RuntimeException::class); - $this->expectExceptionMessage("Invalid 'text' content at index 0"); - - (new PromptResultFormatter())->format([ - ['role' => 'user', 'content' => ['type' => 'text']], - ]); - } - - public function testFormatTypedContentRejectsUnknownType(): void - { - $this->expectException(RuntimeException::class); - $this->expectExceptionMessage("Invalid content type 'video' at index 0."); - - (new PromptResultFormatter())->format([ - ['role' => 'user', 'content' => ['type' => 'video']], - ]); - } -} diff --git a/tests/Unit/Capability/Formatter/ResourceResultFormatterTest.php b/tests/Unit/Capability/Formatter/ResourceResultFormatterTest.php deleted file mode 100644 index e98e617d..00000000 --- a/tests/Unit/Capability/Formatter/ResourceResultFormatterTest.php +++ /dev/null @@ -1,40 +0,0 @@ -format('content', 'file://test'); - $this->assertCount(1, $result); - $this->assertInstanceOf(TextResourceContents::class, $result[0]); - } - - public function testFormatResourceContents(): void - { - $contents = new TextResourceContents('file://test', 'text/plain', 'content'); - $result = (new ResourceResultFormatter())->format($contents, 'file://test'); - $this->assertSame([$contents], $result); - } - - public function testFormatWithMimeType(): void - { - $result = (new ResourceResultFormatter())->format('content', 'file://test', 'text/html'); - $this->assertCount(1, $result); - $this->assertSame('text/html', $result[0]->mimeType); - } -} diff --git a/tests/Unit/Capability/Formatter/ToolResultFormatterTest.php b/tests/Unit/Capability/Formatter/ToolResultFormatterTest.php deleted file mode 100644 index 1c5ee7c1..00000000 --- a/tests/Unit/Capability/Formatter/ToolResultFormatterTest.php +++ /dev/null @@ -1,58 +0,0 @@ -format('hello'); - $this->assertCount(1, $result); - $this->assertInstanceOf(TextContent::class, $result[0]); - $this->assertSame('hello', $result[0]->text); - } - - public function testFormatContentResult(): void - { - $content = new TextContent('test'); - $result = (new ToolResultFormatter())->format($content); - $this->assertSame([$content], $result); - } - - public function testFormatArrayResult(): void - { - $result = (new ToolResultFormatter())->format(['key' => 'value']); - $this->assertCount(1, $result); - $this->assertInstanceOf(TextContent::class, $result[0]); - $this->assertStringContainsString('value', $result[0]->text); - } - - public function testFormatNullResult(): void - { - $result = (new ToolResultFormatter())->format(null); - $this->assertCount(1, $result); - $this->assertInstanceOf(TextContent::class, $result[0]); - $this->assertSame('(null)', $result[0]->text); - } - - public function testFormatBoolResult(): void - { - $result = (new ToolResultFormatter())->format(true); - $this->assertCount(1, $result); - $this->assertInstanceOf(TextContent::class, $result[0]); - $this->assertSame('true', $result[0]->text); - } -} diff --git a/tests/Unit/Capability/Logger/ClientLoggerTest.php b/tests/Unit/Capability/Logger/ClientLoggerTest.php deleted file mode 100644 index 6a443b71..00000000 --- a/tests/Unit/Capability/Logger/ClientLoggerTest.php +++ /dev/null @@ -1,92 +0,0 @@ -getMockBuilder(Session::class) - ->disableOriginalConstructor() - ->onlyMethods(['get']) - ->getMock(); - $session->expects($this->once())->method('get')->willReturn('info'); - $clientGateway = $this->getMockBuilder(ClientGateway::class) - ->disableOriginalConstructor() - ->onlyMethods(['log']) - ->getMock(); - $clientGateway->expects($this->once())->method('log')->with(LoggingLevel::Notice, 'test'); - - $logger = new ClientLogger($clientGateway, $session); - $logger->notice('test'); - } - - public function testLogFilter(): void - { - $session = $this->getMockBuilder(Session::class) - ->disableOriginalConstructor() - ->onlyMethods(['get']) - ->getMock(); - $session->expects($this->once())->method('get')->willReturn('info'); - $clientGateway = $this->getMockBuilder(ClientGateway::class) - ->disableOriginalConstructor() - ->onlyMethods(['log']) - ->getMock(); - $clientGateway->expects($this->never())->method('log'); - - $logger = new ClientLogger($clientGateway, $session); - $logger->debug('test'); - } - - public function testLogFilterSameLevel(): void - { - $session = $this->getMockBuilder(Session::class) - ->disableOriginalConstructor() - ->onlyMethods(['get']) - ->getMock(); - $session->expects($this->once())->method('get')->willReturn('info'); - $clientGateway = $this->getMockBuilder(ClientGateway::class) - ->disableOriginalConstructor() - ->onlyMethods(['log']) - ->getMock(); - $clientGateway->expects($this->once())->method('log'); - - $logger = new ClientLogger($clientGateway, $session); - $logger->info('test'); - } - - public function testLogWithInvalidLevel(): void - { - $session = $this->getMockBuilder(Session::class) - ->disableOriginalConstructor() - ->onlyMethods(['get']) - ->getMock(); - $session->expects($this->any())->method('get')->willReturn('info'); - $clientGateway = $this->getMockBuilder(ClientGateway::class) - ->disableOriginalConstructor() - ->onlyMethods(['log']) - ->getMock(); - $clientGateway->expects($this->never())->method('log'); - - $logger = new ClientLogger($clientGateway, $session); - $logger->log('foo', 'test'); - } -} diff --git a/tests/Unit/Capability/Registry/Loader/ChainLoaderTest.php b/tests/Unit/Capability/Registry/Loader/ChainLoaderTest.php deleted file mode 100644 index 5488818e..00000000 --- a/tests/Unit/Capability/Registry/Loader/ChainLoaderTest.php +++ /dev/null @@ -1,57 +0,0 @@ -load(new Registry()); - - $this->assertSame(['A', 'B', 'C'], $calls->getArrayCopy()); - } - - public function testLastWriterWinsForConflictingKeys(): void - { - $registry = new Registry(); - - $first = new ToolWriterLoader('shared', static fn () => 'first'); - $second = new ToolWriterLoader('shared', static fn () => 'second'); - - (new ChainLoader([$first, $second]))->load($registry); - - $this->assertSame('second', ($registry->getTool('shared')->handler)()); - } - - public function testEmptyChainIsNoop(): void - { - $registry = new Registry(); - - (new ChainLoader([]))->load($registry); - - $this->assertFalse($registry->hasTools()); - $this->assertFalse($registry->hasResources()); - $this->assertFalse($registry->hasResourceTemplates()); - $this->assertFalse($registry->hasPrompts()); - } -} diff --git a/tests/Unit/Capability/Registry/Loader/DiscoveryLoaderTest.php b/tests/Unit/Capability/Registry/Loader/DiscoveryLoaderTest.php deleted file mode 100644 index dcb1d8d4..00000000 --- a/tests/Unit/Capability/Registry/Loader/DiscoveryLoaderTest.php +++ /dev/null @@ -1,284 +0,0 @@ -registry = new Registry(); - } - - public function testLoadRegistersAllDiscoveredElements(): void - { - $loader = new DiscoveryLoader('/base', [], [], new MutableDiscoverer(new DiscoveryState( - tools: ['t1' => new ToolReference($this->makeTool('t1'), static fn () => 't1')], - resources: ['r://1' => new ResourceReference($this->makeResource('r://1'), static fn () => 'r1')], - prompts: ['p1' => new PromptReference($this->makePrompt('p1'), static fn () => [])], - resourceTemplates: ['t://{id}' => new ResourceTemplateReference($this->makeTemplate('t://{id}'), static fn () => 'tpl')], - ))); - - $loader->load($this->registry); - - $this->assertInstanceOf(ToolReference::class, $this->registry->getTool('t1')); - $this->assertInstanceOf(ResourceReference::class, $this->registry->getResource('r://1', false)); - $this->assertInstanceOf(PromptReference::class, $this->registry->getPrompt('p1')); - $this->assertInstanceOf(ResourceTemplateReference::class, $this->registry->getResourceTemplate('t://{id}')); - } - - public function testLoadTwiceUnregistersStaleAndKeepsNew(): void - { - $discoverer = new MutableDiscoverer(new DiscoveryState( - tools: ['t1' => new ToolReference($this->makeTool('t1'), static fn () => 't1')], - resources: ['r://1' => new ResourceReference($this->makeResource('r://1'), static fn () => 'r1')], - )); - $loader = new DiscoveryLoader('/base', [], [], $discoverer); - - $loader->load($this->registry); - - // Second discovery: t1 is gone, t2 appears; resource r://1 still present, new r://2 added. - $discoverer->state = new DiscoveryState( - tools: ['t2' => new ToolReference($this->makeTool('t2'), static fn () => 't2')], - resources: [ - 'r://1' => new ResourceReference($this->makeResource('r://1'), static fn () => 'r1-updated'), - 'r://2' => new ResourceReference($this->makeResource('r://2'), static fn () => 'r2'), - ], - ); - $loader->load($this->registry); - - $this->assertInstanceOf(ToolReference::class, $this->registry->getTool('t2')); - $this->assertInstanceOf(ResourceReference::class, $this->registry->getResource('r://2', false)); - - $updatedResource = $this->registry->getResource('r://1', false); - $this->assertInstanceOf(ResourceReference::class, $updatedResource); - $this->assertSame('r1-updated', ($updatedResource->handler)()); - - $this->expectException(ToolNotFoundException::class); - $this->registry->getTool('t1'); - } - - public function testLoadTwiceUnregistersStalePromptsAndTemplates(): void - { - $discoverer = new MutableDiscoverer(new DiscoveryState( - prompts: [ - 'p1' => new PromptReference($this->makePrompt('p1'), static fn () => []), - 'p2' => new PromptReference($this->makePrompt('p2'), static fn () => []), - ], - resourceTemplates: [ - 't1://{id}' => new ResourceTemplateReference($this->makeTemplate('t1://{id}'), static fn () => 'tpl1'), - 't2://{id}' => new ResourceTemplateReference($this->makeTemplate('t2://{id}'), static fn () => 'tpl2'), - ], - )); - $loader = new DiscoveryLoader('/base', [], [], $discoverer); - $loader->load($this->registry); - - // Second discovery: drop p1 and t1, keep p2 and t2, add p3 and t3. - $discoverer->state = new DiscoveryState( - prompts: [ - 'p2' => new PromptReference($this->makePrompt('p2'), static fn () => []), - 'p3' => new PromptReference($this->makePrompt('p3'), static fn () => []), - ], - resourceTemplates: [ - 't2://{id}' => new ResourceTemplateReference($this->makeTemplate('t2://{id}'), static fn () => 'tpl2'), - 't3://{id}' => new ResourceTemplateReference($this->makeTemplate('t3://{id}'), static fn () => 'tpl3'), - ], - ); - $loader->load($this->registry); - - $this->assertInstanceOf(PromptReference::class, $this->registry->getPrompt('p2')); - $this->assertInstanceOf(PromptReference::class, $this->registry->getPrompt('p3')); - $this->assertInstanceOf(ResourceTemplateReference::class, $this->registry->getResourceTemplate('t2://{id}')); - $this->assertInstanceOf(ResourceTemplateReference::class, $this->registry->getResourceTemplate('t3://{id}')); - - $missing = 0; - try { - $this->registry->getPrompt('p1'); - } catch (PromptNotFoundException) { - ++$missing; - } - try { - $this->registry->getResourceTemplate('t1://{id}'); - } catch (ResourceNotFoundException) { - ++$missing; - } - $this->assertSame(2, $missing); - } - - public function testLoadOverwritesPreviousRegistrationOnSameKey(): void - { - $discoverer = new MutableDiscoverer(new DiscoveryState( - tools: ['t' => new ToolReference($this->makeTool('t'), static fn () => 'v1')], - prompts: ['p' => new PromptReference($this->makePrompt('p'), static fn () => [], ['arg' => EnumCompletionProvider::class])], - )); - $loader = new DiscoveryLoader('/base', [], [], $discoverer); - $loader->load($this->registry); - - // Same names, different handlers / completion providers. - $discoverer->state = new DiscoveryState( - tools: ['t' => new ToolReference($this->makeTool('t'), static fn () => 'v2')], - prompts: ['p' => new PromptReference($this->makePrompt('p'), static fn () => [], ['arg' => ListCompletionProvider::class])], - ); - $loader->load($this->registry); - - $this->assertSame('v2', ($this->registry->getTool('t')->handler)()); - $this->assertSame(['arg' => ListCompletionProvider::class], $this->registry->getPrompt('p')->completionProviders); - } - - public function testLoadPreservesConflictingRuntimeRegistration(): void - { - // Application registers a tool directly. - $this->registry->registerTool($this->makeTool('shared'), static fn () => 'runtime'); - - // Discovery later finds the same name. The manual registration wins — - // discovery does not clobber entries it doesn't own. - $discoverer = new MutableDiscoverer(new DiscoveryState( - tools: ['shared' => new ToolReference($this->makeTool('shared'), static fn () => 'discovered')], - )); - (new DiscoveryLoader('/base', [], [], $discoverer))->load($this->registry); - - $this->assertSame('runtime', ($this->registry->getTool('shared')->handler)()); - } - - public function testLoadPreservesRuntimeOverrideOfPreviouslyOwnedEntry(): void - { - // First discovery owns 'shared'. - $discoverer = new MutableDiscoverer(new DiscoveryState( - tools: ['shared' => new ToolReference($this->makeTool('shared'), static fn () => 'discovered-v1')], - )); - $loader = new DiscoveryLoader('/base', [], [], $discoverer); - $loader->load($this->registry); - - // Developer overrides at runtime. - $this->registry->registerTool($this->makeTool('shared'), static fn () => 'runtime'); - - // Rediscovery still finds 'shared'; loader sees the registry no longer holds its instance and steps aside. - $discoverer->state = new DiscoveryState( - tools: ['shared' => new ToolReference($this->makeTool('shared'), static fn () => 'discovered-v2')], - ); - $loader->load($this->registry); - - $this->assertSame('runtime', ($this->registry->getTool('shared')->handler)()); - } - - public function testLoadDoesNotUnregisterRuntimeAdditions(): void - { - $discoverer = new MutableDiscoverer(new DiscoveryState( - tools: ['discovered_tool' => new ToolReference($this->makeTool('discovered_tool'), static fn () => 'discovered')], - )); - $loader = new DiscoveryLoader('/base', [], [], $discoverer); - - $loader->load($this->registry); - - // Application registers a tool directly between two discovery runs. - $this->registry->registerTool($this->makeTool('runtime_tool'), static fn () => 'runtime'); - - // Second discovery run with a different state. The runtime tool must survive. - $discoverer->state = new DiscoveryState( - tools: ['discovered_tool_v2' => new ToolReference($this->makeTool('discovered_tool_v2'), static fn () => 'v2')], - ); - $loader->load($this->registry); - - $this->assertInstanceOf(ToolReference::class, $this->registry->getTool('runtime_tool')); - $this->assertInstanceOf(ToolReference::class, $this->registry->getTool('discovered_tool_v2')); - - $this->expectException(ToolNotFoundException::class); - $this->registry->getTool('discovered_tool'); - } - - public function testEmptySecondLoadUnregistersAllPreviouslyDiscovered(): void - { - $discoverer = new MutableDiscoverer(new DiscoveryState( - tools: ['t' => new ToolReference($this->makeTool('t'), static fn () => 't')], - resources: ['r://x' => new ResourceReference($this->makeResource('r://x'), static fn () => 'rx')], - prompts: ['p' => new PromptReference($this->makePrompt('p'), static fn () => [])], - resourceTemplates: ['x://{id}' => new ResourceTemplateReference($this->makeTemplate('x://{id}'), static fn () => 'tpl')], - )); - $loader = new DiscoveryLoader('/base', [], [], $discoverer); - $loader->load($this->registry); - - $discoverer->state = new DiscoveryState(); - $loader->load($this->registry); - - $exceptions = 0; - try { - $this->registry->getTool('t'); - } catch (ToolNotFoundException) { - ++$exceptions; - } - try { - $this->registry->getResource('r://x', false); - } catch (ResourceNotFoundException) { - ++$exceptions; - } - try { - $this->registry->getPrompt('p'); - } catch (PromptNotFoundException) { - ++$exceptions; - } - try { - $this->registry->getResourceTemplate('x://{id}'); - } catch (ResourceNotFoundException) { - ++$exceptions; - } - $this->assertSame(4, $exceptions); - } - - private function makeTool(string $name): Tool - { - return new Tool( - name: $name, - title: null, - inputSchema: ['type' => 'object', 'properties' => [], 'required' => null], - description: null, - annotations: null, - icons: null, - meta: null, - outputSchema: null, - ); - } - - private function makeResource(string $uri): ResourceDefinition - { - return new ResourceDefinition(uri: $uri, name: 'r', description: null, mimeType: 'text/plain'); - } - - private function makePrompt(string $name): Prompt - { - return new Prompt(name: $name, description: null, arguments: []); - } - - private function makeTemplate(string $uriTemplate): ResourceTemplate - { - return new ResourceTemplate(uriTemplate: $uriTemplate, name: 'tpl', description: null, mimeType: 'text/plain'); - } -} diff --git a/tests/Unit/Capability/Registry/Loader/ExplicitElementLoaderTest.php b/tests/Unit/Capability/Registry/Loader/ExplicitElementLoaderTest.php deleted file mode 100644 index dad2de64..00000000 --- a/tests/Unit/Capability/Registry/Loader/ExplicitElementLoaderTest.php +++ /dev/null @@ -1,382 +0,0 @@ - 'object', 'properties' => ['foo' => ['type' => 'string']], 'required' => []], - description: 'A demo tool', - annotations: null, - ); - $handler = new class implements ToolHandlerInterface { - /** @var array|null */ - public ?array $receivedArguments = null; - public ?ClientGateway $receivedGateway = null; - - public function execute(array $arguments, ClientGateway $gateway): mixed - { - $this->receivedArguments = $arguments; - $this->receivedGateway = $gateway; - - return 'tool-ok'; - } - }; - - $registry = $this->buildAndGetRegistry(static fn (Server\Builder $b) => $b->add($tool, $handler)); - - $reference = $registry->getTool('demo'); - $this->assertSame('demo', $reference->tool->name); - $this->assertSame('A demo tool', $reference->tool->description); - $this->assertSame(['type' => 'object', 'properties' => ['foo' => ['type' => 'string']], 'required' => []], $reference->tool->inputSchema); - - $session = $this->createMock(SessionInterface::class); - $session->method('getId')->willReturn(Uuid::v4()); - - $result = (new ReferenceHandler())->handle($reference, [ - '_session' => $session, - '_request' => new \stdClass(), - 'foo' => 'bar', - ]); - - $this->assertSame('tool-ok', $result); - $this->assertSame(['foo' => 'bar'], $handler->receivedArguments); - $this->assertInstanceOf(ClientGateway::class, $handler->receivedGateway); - } - - public function testAddResourceRegistersDefinitionAndDispatchesToHandler(): void - { - $resource = new ResourceDefinition( - uri: 'config://demo', - name: 'demo', - description: 'A demo resource', - mimeType: 'text/plain', - ); - $handler = new class implements ResourceHandlerInterface { - public ?string $receivedUri = null; - public ?ClientGateway $receivedGateway = null; - - public function read(string $uri, ClientGateway $gateway): mixed - { - $this->receivedUri = $uri; - $this->receivedGateway = $gateway; - - return ['contents' => 'resource-ok']; - } - }; - - $registry = $this->buildAndGetRegistry(static fn (Server\Builder $b) => $b->add($resource, $handler)); - - $reference = $registry->getResource('config://demo', false); - $this->assertSame('config://demo', $reference->resource->uri); - $this->assertSame('demo', $reference->resource->name); - $this->assertSame('text/plain', $reference->resource->mimeType); - - $session = $this->createMock(SessionInterface::class); - $session->method('getId')->willReturn(Uuid::v4()); - - $result = (new ReferenceHandler())->handle($reference, [ - '_session' => $session, - '_request' => new \stdClass(), - 'uri' => 'config://demo', - ]); - - $this->assertSame(['contents' => 'resource-ok'], $result); - $this->assertSame('config://demo', $handler->receivedUri); - $this->assertInstanceOf(ClientGateway::class, $handler->receivedGateway); - } - - public function testAddResourceTemplateRegistersDefinitionAndDispatchesToHandler(): void - { - $template = new ResourceTemplate( - uriTemplate: 'config://{key}', - name: 'config_template', - description: 'A demo template', - ); - $handler = new class implements ResourceTemplateHandlerInterface { - public ?string $receivedUri = null; - /** @var array|null */ - public ?array $receivedVariables = null; - public ?ClientGateway $receivedGateway = null; - - public function read(string $uri, array $variables, ClientGateway $gateway): mixed - { - $this->receivedUri = $uri; - $this->receivedVariables = $variables; - $this->receivedGateway = $gateway; - - return ['contents' => 'template-ok']; - } - }; - - $registry = $this->buildAndGetRegistry(static fn (Server\Builder $b) => $b->add($template, $handler)); - - $reference = $registry->getResourceTemplate('config://{key}'); - $this->assertSame('config://{key}', $reference->resourceTemplate->uriTemplate); - $this->assertSame('config_template', $reference->resourceTemplate->name); - - $session = $this->createMock(SessionInterface::class); - $session->method('getId')->willReturn(Uuid::v4()); - - $result = (new ReferenceHandler())->handle($reference, [ - '_session' => $session, - '_request' => new \stdClass(), - 'uri' => 'config://abc', - 'key' => 'abc', - ]); - - $this->assertSame(['contents' => 'template-ok'], $result); - $this->assertSame('config://abc', $handler->receivedUri); - $this->assertSame(['key' => 'abc'], $handler->receivedVariables); - $this->assertInstanceOf(ClientGateway::class, $handler->receivedGateway); - } - - public function testAddPromptRegistersDefinitionAndDispatchesToHandler(): void - { - $prompt = new Prompt( - name: 'demo_prompt', - title: null, - description: 'A demo prompt', - ); - $handler = new class implements PromptHandlerInterface { - /** @var array|null */ - public ?array $receivedArguments = null; - public ?ClientGateway $receivedGateway = null; - - public function get(array $arguments, ClientGateway $gateway): mixed - { - $this->receivedArguments = $arguments; - $this->receivedGateway = $gateway; - - return 'prompt-ok'; - } - }; - - $registry = $this->buildAndGetRegistry(static fn (Server\Builder $b) => $b->add($prompt, $handler)); - - $reference = $registry->getPrompt('demo_prompt'); - $this->assertSame('demo_prompt', $reference->prompt->name); - $this->assertSame('A demo prompt', $reference->prompt->description); - - $session = $this->createMock(SessionInterface::class); - $session->method('getId')->willReturn(Uuid::v4()); - - $result = (new ReferenceHandler())->handle($reference, [ - '_session' => $session, - '_request' => new \stdClass(), - 'topic' => 'php', - ]); - - $this->assertSame('prompt-ok', $result); - $this->assertSame(['topic' => 'php'], $handler->receivedArguments); - $this->assertInstanceOf(ClientGateway::class, $handler->receivedGateway); - } - - public function testAddPromptForwardsCompletionProvidersToRegistry(): void - { - $prompt = new Prompt(name: 'greet', title: null, description: null); - $handler = new class implements PromptHandlerInterface { - public function get(array $arguments, ClientGateway $gateway): mixed - { - return null; - } - }; - $provider = new class implements ProviderInterface { - public function getCompletions(string $currentValue): array - { - return ['alice', 'bob']; - } - }; - - $registry = $this->buildAndGetRegistry(static fn (Server\Builder $b) => $b->add( - $prompt, - $handler, - ['name' => $provider], - )); - - $reference = $registry->getPrompt('greet'); - $this->assertSame(['name' => $provider], $reference->completionProviders); - } - - public function testAddResourceTemplateForwardsCompletionProvidersToRegistry(): void - { - $template = new ResourceTemplate( - uriTemplate: 'config://{key}', - name: 'config_template', - description: null, - ); - $handler = new class implements ResourceTemplateHandlerInterface { - public function read(string $uri, array $variables, ClientGateway $gateway): mixed - { - return null; - } - }; - $provider = new class implements ProviderInterface { - public function getCompletions(string $currentValue): array - { - return ['alpha', 'beta']; - } - }; - - $registry = $this->buildAndGetRegistry(static fn (Server\Builder $b) => $b->add( - $template, - $handler, - ['key' => $provider], - )); - - $reference = $registry->getResourceTemplate('config://{key}'); - $this->assertSame(['key' => $provider], $reference->completionProviders); - } - - public function testAddPromptWithoutCompletionProvidersDefaultsToEmptyArray(): void - { - $prompt = new Prompt(name: 'no_completion', title: null, description: null); - $handler = new class implements PromptHandlerInterface { - public function get(array $arguments, ClientGateway $gateway): mixed - { - return null; - } - }; - - $registry = $this->buildAndGetRegistry(static fn (Server\Builder $b) => $b->add($prompt, $handler)); - - $this->assertSame([], $registry->getPrompt('no_completion')->completionProviders); - } - - public function testAddToolWithCompletionProvidersThrows(): void - { - $tool = new Tool( - name: 'noop', - title: null, - inputSchema: ['type' => 'object', 'properties' => [], 'required' => []], - description: null, - annotations: null, - ); - $handler = new class implements ToolHandlerInterface { - public function execute(array $arguments, ClientGateway $gateway): mixed - { - return null; - } - }; - $provider = new class implements ProviderInterface { - public function getCompletions(string $currentValue): array - { - return []; - } - }; - - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Completion providers are only supported on Prompt and ResourceTemplate'); - - Server::builder() - ->setServerInfo('test', '1.0.0') - ->add($tool, $handler, ['foo' => $provider]); - } - - public function testAddResourceWithCompletionProvidersThrows(): void - { - $resource = new ResourceDefinition(uri: 'config://demo', name: 'demo'); - $handler = new class implements ResourceHandlerInterface { - public function read(string $uri, ClientGateway $gateway): mixed - { - return null; - } - }; - $provider = new class implements ProviderInterface { - public function getCompletions(string $currentValue): array - { - return []; - } - }; - - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Completion providers are only supported on Prompt and ResourceTemplate'); - - Server::builder() - ->setServerInfo('test', '1.0.0') - ->add($resource, $handler, ['foo' => $provider]); - } - - public function testMismatchedDefinitionAndHandlerThrowsInvalidArgumentException(): void - { - $prompt = new Prompt(name: 'mismatched', title: null, description: null); - $toolHandler = new class implements ToolHandlerInterface { - public function execute(array $arguments, ClientGateway $gateway): mixed - { - return null; - } - }; - - $this->expectException(InvalidArgumentException::class); - - Server::builder() - ->setServerInfo('test', '1.0.0') - ->add($prompt, $toolHandler); - } - - public function testLoaderRegistersClosuresRatherThanHandlerInstances(): void - { - $tool = new Tool( - name: 'closure_check', - title: null, - inputSchema: ['type' => 'object', 'properties' => [], 'required' => []], - description: null, - annotations: null, - ); - $handler = new class implements ToolHandlerInterface { - public function execute(array $arguments, ClientGateway $gateway): mixed - { - return null; - } - }; - - $registry = $this->buildAndGetRegistry(static fn (Server\Builder $b) => $b->add($tool, $handler)); - - $reference = $registry->getTool('closure_check'); - $this->assertInstanceOf(\Closure::class, $reference->handler); - } - - /** - * @param callable(Server\Builder): Server\Builder $configure - */ - private function buildAndGetRegistry(callable $configure): RegistryInterface - { - // A caller-supplied registry is loaded eagerly at build, so it is populated once build() returns. - $registry = new Registry(); - $configure(Server::builder()->setServerInfo('test', '1.0.0')->setRegistry($registry))->build(); - - return $registry; - } -} diff --git a/tests/Unit/Capability/Registry/Loader/ReflectedElementLoaderResourceTitleTest.php b/tests/Unit/Capability/Registry/Loader/ReflectedElementLoaderResourceTitleTest.php deleted file mode 100644 index 6dd09991..00000000 --- a/tests/Unit/Capability/Registry/Loader/ReflectedElementLoaderResourceTitleTest.php +++ /dev/null @@ -1,69 +0,0 @@ - static fn (): string => 'ok', - 'uri' => 'config://app/settings', - 'name' => 'app_settings', - 'title' => 'Application Settings', - 'description' => null, - 'mimeType' => null, - 'size' => null, - 'annotations' => null, - 'icons' => null, - 'meta' => null, - ], - ]; - - $loader = new ReflectedElementLoader([], $resources); - $registry = new Registry(); - - $loader->load($registry); - - $resourceRef = $registry->getResource('config://app/settings'); - $this->assertSame('Application Settings', $resourceRef->resource->title); - } - - public function testLoadPropagatesResourceTemplateTitleToRegisteredTemplate(): void - { - $resourceTemplates = [ - [ - 'handler' => static fn (): string => 'ok', - 'uriTemplate' => 'user://{userId}/profile', - 'name' => 'user_profile', - 'title' => 'User Profile', - 'description' => null, - 'mimeType' => null, - 'annotations' => null, - 'meta' => null, - ], - ]; - - $loader = new ReflectedElementLoader([], [], $resourceTemplates); - $registry = new Registry(); - - $loader->load($registry); - - $templateRef = $registry->getResourceTemplate('user://{userId}/profile'); - $this->assertSame('User Profile', $templateRef->resourceTemplate->title); - } -} diff --git a/tests/Unit/Capability/Registry/Loader/ReflectedElementLoaderToolTitleTest.php b/tests/Unit/Capability/Registry/Loader/ReflectedElementLoaderToolTitleTest.php deleted file mode 100644 index 8537fcc8..00000000 --- a/tests/Unit/Capability/Registry/Loader/ReflectedElementLoaderToolTitleTest.php +++ /dev/null @@ -1,48 +0,0 @@ - static fn (): string => 'ok', - 'name' => 'weather_lookup', - 'title' => 'Weather Lookup', - 'description' => null, - 'annotations' => null, - 'inputSchema' => [ - 'type' => 'object', - 'properties' => new \stdClass(), - 'required' => null, - ], - 'icons' => null, - 'meta' => null, - 'outputSchema' => null, - ], - ]; - - $loader = new ReflectedElementLoader($tools); - $registry = new Registry(); - - $loader->load($registry); - - $toolRef = $registry->getTool('weather_lookup'); - $this->assertSame('Weather Lookup', $toolRef->tool->title); - } -} diff --git a/tests/Unit/Capability/Registry/Loader/Stub/MutableDiscoverer.php b/tests/Unit/Capability/Registry/Loader/Stub/MutableDiscoverer.php deleted file mode 100644 index 0b5b298b..00000000 --- a/tests/Unit/Capability/Registry/Loader/Stub/MutableDiscoverer.php +++ /dev/null @@ -1,27 +0,0 @@ -state; - } -} diff --git a/tests/Unit/Capability/Registry/Loader/Stub/RecordingLoader.php b/tests/Unit/Capability/Registry/Loader/Stub/RecordingLoader.php deleted file mode 100644 index a1eccae1..00000000 --- a/tests/Unit/Capability/Registry/Loader/Stub/RecordingLoader.php +++ /dev/null @@ -1,30 +0,0 @@ - $calls - */ - public function __construct(private string $name, private \ArrayObject $calls) - { - } - - public function load(RegistryInterface $registry): void - { - $this->calls->append($this->name); - } -} diff --git a/tests/Unit/Capability/Registry/Loader/Stub/ToolWriterLoader.php b/tests/Unit/Capability/Registry/Loader/Stub/ToolWriterLoader.php deleted file mode 100644 index d8ae3f13..00000000 --- a/tests/Unit/Capability/Registry/Loader/Stub/ToolWriterLoader.php +++ /dev/null @@ -1,37 +0,0 @@ -registerTool(new Tool( - name: $this->toolName, - title: null, - inputSchema: ['type' => 'object', 'properties' => [], 'required' => null], - description: null, - annotations: null, - icons: null, - meta: null, - outputSchema: null, - ), $this->handler); - } -} diff --git a/tests/Unit/Capability/Registry/ReferenceHandlerTest.php b/tests/Unit/Capability/Registry/ReferenceHandlerTest.php deleted file mode 100644 index dadca9f5..00000000 --- a/tests/Unit/Capability/Registry/ReferenceHandlerTest.php +++ /dev/null @@ -1,144 +0,0 @@ -createMock(SessionInterface::class); - $session->method('getId')->willReturn(Uuid::v4()); - - $toolHandler = new class implements ToolHandlerInterface { - /** @var array|null */ - public ?array $executedWith = null; - public ?ClientGateway $receivedGateway = null; - - public function execute(array $arguments, ClientGateway $gateway): mixed - { - $this->executedWith = $arguments; - $this->receivedGateway = $gateway; - - return 'tool-result'; - } - }; - - $closure = \Closure::bind( - static function (array $arguments) use ($toolHandler): mixed { - $gateway = new ClientGateway($arguments['_session']); - unset($arguments['_session'], $arguments['_request']); - - return $toolHandler->execute($arguments, $gateway); - }, - null, - ReferenceHandler::class, - ); - $reference = new ElementReference($closure); - - $result = (new ReferenceHandler())->handle($reference, [ - '_session' => $session, - '_request' => new \stdClass(), - 'kept' => 'value', - 'other' => 'value2', - ]); - - $this->assertSame('tool-result', $result); - $this->assertSame( - ['kept' => 'value', 'other' => 'value2'], - $toolHandler->executedWith, - ); - $this->assertInstanceOf(ClientGateway::class, $toolHandler->receivedGateway); - } - - public function testHandleDispatchesToBoundResourceClosureWithRawArgumentBag(): void - { - $session = $this->createMock(SessionInterface::class); - $session->method('getId')->willReturn(Uuid::v4()); - - $resourceHandler = new class implements ResourceHandlerInterface { - public ?string $receivedUri = null; - public ?ClientGateway $receivedGateway = null; - - public function read(string $uri, ClientGateway $gateway): mixed - { - $this->receivedUri = $uri; - $this->receivedGateway = $gateway; - - return ['contents' => 'r-ok']; - } - }; - - $closure = \Closure::bind( - static fn (array $arguments): mixed => $resourceHandler->read( - $arguments['uri'], - new ClientGateway($arguments['_session']), - ), - null, - ReferenceHandler::class, - ); - $reference = new ElementReference($closure); - - $result = (new ReferenceHandler())->handle($reference, [ - '_session' => $session, - '_request' => new \stdClass(), - 'uri' => 'config://x', - ]); - - $this->assertSame(['contents' => 'r-ok'], $result); - $this->assertSame('config://x', $resourceHandler->receivedUri); - $this->assertInstanceOf(ClientGateway::class, $resourceHandler->receivedGateway); - } - - public function testHandleStillReflectsOrdinaryClosuresAndDoesNotInjectArgumentBag(): void - { - $session = $this->createMock(SessionInterface::class); - $session->method('getId')->willReturn(Uuid::v4()); - - $captured = null; - $closure = static function (string $kept) use (&$captured): string { - $captured = $kept; - - return $kept; - }; - $reference = new ElementReference($closure); - - $result = (new ReferenceHandler())->handle($reference, [ - '_session' => $session, - '_request' => new \stdClass(), - 'kept' => 'value', - ]); - - $this->assertSame('value', $result); - $this->assertSame('value', $captured); - } - - public function testHandleThrowsForStringHandlerThatIsNeitherFunctionNorClass(): void - { - $session = $this->createMock(SessionInterface::class); - - $reference = new ElementReference('definitely_not_a_function_or_class_xyz'); - - $this->expectException(InvalidArgumentException::class); - - (new ReferenceHandler())->handle($reference, ['_session' => $session]); - } -} diff --git a/tests/Unit/Capability/RegistryTest.php b/tests/Unit/Capability/RegistryTest.php deleted file mode 100644 index 9ea4b24e..00000000 --- a/tests/Unit/Capability/RegistryTest.php +++ /dev/null @@ -1,804 +0,0 @@ -logger = $this->createMock(LoggerInterface::class); - $this->registry = new Registry(null, $this->logger); - } - - public function testHasserReturnFalseForEmptyRegistry(): void - { - $this->assertFalse($this->registry->hasTools()); - $this->assertFalse($this->registry->hasResources()); - $this->assertFalse($this->registry->hasResourceTemplates()); - $this->assertFalse($this->registry->hasPrompts()); - } - - public function testHasToolsReturnsTrueWhenToolIsRegistered(): void - { - $tool = $this->createValidTool('test_tool'); - $this->registry->registerTool($tool, static fn () => 'result'); - - $this->assertTrue($this->registry->hasTools()); - } - - public function testGetToolsReturnsAllRegisteredTools(): void - { - $tool1 = $this->createValidTool('tool1'); - $tool2 = $this->createValidTool('tool2'); - - $this->registry->registerTool($tool1, static fn () => 'result1'); - $this->registry->registerTool($tool2, static fn () => 'result2'); - - $tools = $this->registry->getTools(); - $this->assertCount(2, $tools); - $this->assertArrayHasKey('tool1', $tools->references); - $this->assertArrayHasKey('tool2', $tools->references); - $this->assertInstanceOf(Tool::class, $tools->references['tool1']); - $this->assertInstanceOf(Tool::class, $tools->references['tool2']); - } - - public function testGetToolReturnsRegisteredTool(): void - { - $tool = $this->createValidTool('test_tool'); - $handler = static fn () => 'result'; - - $this->registry->registerTool($tool, $handler); - - $toolRef = $this->registry->getTool('test_tool'); - $this->assertInstanceOf(ToolReference::class, $toolRef); - $this->assertEquals($tool->name, $toolRef->tool->name); - $this->assertEquals($handler, $toolRef->handler); - } - - public function testRegisterToolOverwritesPriorRegistration(): void - { - $first = $this->createValidTool('test_tool'); - $second = $this->createValidTool('test_tool'); - - $this->registry->registerTool($first, static fn () => 'first'); - $this->registry->registerTool($second, static fn () => 'second'); - - $toolRef = $this->registry->getTool('test_tool'); - $this->assertEquals('second', ($toolRef->handler)()); - } - - public function testGetToolThrowsExceptionForUnregisteredTool(): void - { - $this->expectException(ToolNotFoundException::class); - $this->expectExceptionMessage('Tool not found: "non_existent_tool".'); - - $this->registry->getTool('non_existent_tool'); - } - - public function testHasResourceReturnsTrueWhenResourceIsRegistered(): void - { - $resource = $this->createValidResource('test://resource'); - $this->registry->registerResource($resource, static fn () => 'content'); - - $this->assertTrue($this->registry->hasResources()); - } - - public function testGetResourcesReturnsAllRegisteredResources(): void - { - $resource1 = $this->createValidResource('test://resource1'); - $resource2 = $this->createValidResource('test://resource2'); - - $this->registry->registerResource($resource1, static fn () => 'content1'); - $this->registry->registerResource($resource2, static fn () => 'content2'); - - $resources = $this->registry->getResources(); - $this->assertCount(2, $resources); - $this->assertArrayHasKey('test://resource1', $resources->references); - $this->assertArrayHasKey('test://resource2', $resources->references); - $this->assertInstanceOf(ResourceDefinition::class, $resources->references['test://resource1']); - $this->assertInstanceOf(ResourceDefinition::class, $resources->references['test://resource2']); - } - - public function testGetResourceReturnsRegisteredResource(): void - { - $resource = $this->createValidResource('test://resource'); - $handler = static fn () => 'content'; - - $this->registry->registerResource($resource, $handler); - - $resourceRef = $this->registry->getResource('test://resource'); - $this->assertInstanceOf(ResourceReference::class, $resourceRef); - $this->assertEquals($resource->uri, $resourceRef->resource->uri); - $this->assertEquals($handler, $resourceRef->handler); - } - - public function testRegisterResourceOverwritesPriorRegistration(): void - { - $first = $this->createValidResource('test://resource'); - $second = $this->createValidResource('test://resource'); - - $this->registry->registerResource($first, static fn () => 'first'); - $this->registry->registerResource($second, static fn () => 'second'); - - $resourceRef = $this->registry->getResource('test://resource'); - $this->assertEquals('second', ($resourceRef->handler)()); - } - - public function testGetResourceThrowsExceptionForUnregisteredResource(): void - { - $this->expectException(ResourceNotFoundException::class); - $this->expectExceptionMessage('Resource not found for uri: "test://non_existent".'); - - $this->registry->getResource('test://non_existent'); - } - - public function testHasResourceTemplatesReturnsTrueWhenResourceTemplateIsRegistered(): void - { - $template = $this->createValidResourceTemplate('test://{id}'); - $this->registry->registerResourceTemplate($template, static fn () => 'content'); - - $this->assertTrue($this->registry->hasResourceTemplates()); - } - - public function testGetResourceTemplatesReturnsAllRegisteredTemplates(): void - { - $template1 = $this->createValidResourceTemplate('test1://{id}'); - $template2 = $this->createValidResourceTemplate('test2://{category}'); - - $this->registry->registerResourceTemplate($template1, static fn () => 'content1'); - $this->registry->registerResourceTemplate($template2, static fn () => 'content2'); - - $templates = $this->registry->getResourceTemplates(); - $this->assertCount(2, $templates); - $this->assertArrayHasKey('test1://{id}', $templates->references); - $this->assertArrayHasKey('test2://{category}', $templates->references); - $this->assertInstanceOf(ResourceTemplate::class, $templates->references['test1://{id}']); - $this->assertInstanceOf(ResourceTemplate::class, $templates->references['test2://{category}']); - } - - public function testGetResourceTemplateReturnsRegisteredTemplate(): void - { - $template = $this->createValidResourceTemplate('test://{id}'); - $handler = static fn (string $id) => "content for {$id}"; - - $this->registry->registerResourceTemplate($template, $handler); - - $templateRef = $this->registry->getResourceTemplate('test://{id}'); - $this->assertInstanceOf(ResourceTemplateReference::class, $templateRef); - $this->assertEquals($template->uriTemplate, $templateRef->resourceTemplate->uriTemplate); - $this->assertEquals($handler, $templateRef->handler); - } - - public function testGetResourcePrefersDirectResourceOverTemplate(): void - { - $resource = $this->createValidResource('test://123'); - $resourceHandler = static fn () => 'direct resource'; - - $template = $this->createValidResourceTemplate('test://{id}'); - $templateHandler = static fn (string $id) => "template for {$id}"; - - $this->registry->registerResource($resource, $resourceHandler); - $this->registry->registerResourceTemplate($template, $templateHandler); - - $resourceRef = $this->registry->getResource('test://123'); - $this->assertInstanceOf(ResourceReference::class, $resourceRef); - $this->assertEquals($resource->uri, $resourceRef->resource->uri); - } - - public function testGetResourceMatchesResourceTemplate(): void - { - $template = $this->createValidResourceTemplate('test://{id}'); - $handler = static fn (string $id) => "content for {$id}"; - - $this->registry->registerResourceTemplate($template, $handler); - - $resourceRef = $this->registry->getResource('test://123'); - $this->assertInstanceOf(ResourceTemplateReference::class, $resourceRef); - $this->assertEquals($template->uriTemplate, $resourceRef->resourceTemplate->uriTemplate); - $this->assertEquals($handler, $resourceRef->handler); - } - - public function testGetResourceWithIncludeTemplatesFalseThrowsException(): void - { - $template = $this->createValidResourceTemplate('test://{id}'); - $handler = static fn (string $id) => "content for {$id}"; - - $this->registry->registerResourceTemplate($template, $handler); - - $this->expectException(ResourceNotFoundException::class); - $this->expectExceptionMessage('Resource not found for uri: "test://123".'); - - $this->registry->getResource('test://123', false); - } - - public function testRegisterResourceTemplateWithCompletionProviders(): void - { - $template = $this->createValidResourceTemplate('test://{id}'); - $completionProviders = ['id' => EnumCompletionProvider::class]; - - $this->registry->registerResourceTemplate($template, static fn () => 'content', $completionProviders); - - $templateRef = $this->registry->getResourceTemplate('test://{id}'); - $this->assertEquals($completionProviders, $templateRef->completionProviders); - } - - public function testRegisterResourceTemplateOverwritesPriorRegistration(): void - { - $first = $this->createValidResourceTemplate('test://{id}'); - $second = $this->createValidResourceTemplate('test://{id}'); - - $this->registry->registerResourceTemplate($first, static fn () => 'first'); - $this->registry->registerResourceTemplate($second, static fn () => 'second'); - - $templateRef = $this->registry->getResourceTemplate('test://{id}'); - $this->assertEquals('second', ($templateRef->handler)()); - } - - public function testResourceTemplateMatchingPrefersMoreSpecificMatches(): void - { - $specificTemplate = $this->createValidResourceTemplate('test://users/{userId}/profile'); - $genericTemplate = $this->createValidResourceTemplate('test://users/{userId}'); - - $this->registry->registerResourceTemplate($genericTemplate, static fn () => 'generic'); - $this->registry->registerResourceTemplate($specificTemplate, static fn () => 'specific'); - - // Should match the more specific template first - $resourceRef = $this->registry->getResource('test://users/123/profile'); - $this->assertInstanceOf(ResourceTemplateReference::class, $resourceRef); - $this->assertEquals('test://users/{userId}/profile', $resourceRef->resourceTemplate->uriTemplate); - } - - public function testGetResourceTemplateThrowsExceptionForUnregisteredTemplate(): void - { - $this->expectException(ResourceNotFoundException::class); - $this->expectExceptionMessage('Resource not found for uri: "test://{non_existent}".'); - - $this->registry->getResourceTemplate('test://{non_existent}'); - } - - public function testHasPromptsReturnsTrueWhenPromptIsRegistered(): void - { - $prompt = $this->createValidPrompt('test_prompt'); - $this->registry->registerPrompt($prompt, static fn () => []); - - $this->assertTrue($this->registry->hasPrompts()); - } - - public function testGetPromptsReturnsAllRegisteredPrompts(): void - { - $prompt1 = $this->createValidPrompt('prompt1'); - $prompt2 = $this->createValidPrompt('prompt2'); - - $this->registry->registerPrompt($prompt1, static fn () => []); - $this->registry->registerPrompt($prompt2, static fn () => []); - - $prompts = $this->registry->getPrompts(); - $this->assertCount(2, $prompts); - $this->assertArrayHasKey('prompt1', $prompts->references); - $this->assertArrayHasKey('prompt2', $prompts->references); - $this->assertInstanceOf(Prompt::class, $prompts->references['prompt1']); - $this->assertInstanceOf(Prompt::class, $prompts->references['prompt2']); - } - - public function testGetPromptReturnsRegisteredPrompt(): void - { - $prompt = $this->createValidPrompt('test_prompt'); - $handler = static fn () => ['role' => 'user', 'content' => 'test message']; - - $this->registry->registerPrompt($prompt, $handler); - - $promptRef = $this->registry->getPrompt('test_prompt'); - $this->assertInstanceOf(PromptReference::class, $promptRef); - $this->assertEquals($prompt->name, $promptRef->prompt->name); - $this->assertEquals($handler, $promptRef->handler); - } - - public function testRegisterPromptWithCompletionProviders(): void - { - $prompt = $this->createValidPrompt('test_prompt'); - $completionProviders = ['param' => EnumCompletionProvider::class]; - - $this->registry->registerPrompt($prompt, static fn () => [], $completionProviders); - - $promptRef = $this->registry->getPrompt('test_prompt'); - $this->assertEquals($completionProviders, $promptRef->completionProviders); - } - - public function testRegisterPromptOverwritesPriorRegistration(): void - { - $first = $this->createValidPrompt('test_prompt'); - $second = $this->createValidPrompt('test_prompt'); - - $this->registry->registerPrompt($first, static fn () => 'first'); - $this->registry->registerPrompt($second, static fn () => 'second'); - - $promptRef = $this->registry->getPrompt('test_prompt'); - $this->assertEquals('second', ($promptRef->handler)()); - } - - public function testGetPromptThrowsExceptionForUnregisteredPrompt(): void - { - $this->expectException(PromptNotFoundException::class); - $this->expectExceptionMessage('Prompt not found: "non_existent_prompt".'); - - $this->registry->getPrompt('non_existent_prompt'); - } - - public function testUnregisterToolRemovesRegisteredTool(): void - { - $tool = $this->createValidTool('test_tool'); - $this->registry->registerTool($tool, static fn () => 'result'); - - $this->registry->unregisterTool('test_tool'); - - $this->expectException(ToolNotFoundException::class); - $this->registry->getTool('test_tool'); - } - - public function testUnregisterToolIsIdempotentForAbsentName(): void - { - $this->registry->unregisterTool('never_registered'); - - $this->assertFalse($this->registry->hasTools()); - } - - public function testUnregisterResourceRemovesRegisteredResource(): void - { - $resource = $this->createValidResource('test://resource'); - $this->registry->registerResource($resource, static fn () => 'content'); - - $this->registry->unregisterResource('test://resource'); - - $this->expectException(ResourceNotFoundException::class); - $this->registry->getResource('test://resource', false); - } - - public function testUnregisterResourceTemplateRemovesRegisteredTemplate(): void - { - $template = $this->createValidResourceTemplate('test://{id}'); - $this->registry->registerResourceTemplate($template, static fn () => 'content'); - - $this->registry->unregisterResourceTemplate('test://{id}'); - - $this->expectException(ResourceNotFoundException::class); - $this->registry->getResourceTemplate('test://{id}'); - } - - public function testUnregisterPromptRemovesRegisteredPrompt(): void - { - $prompt = $this->createValidPrompt('test_prompt'); - $this->registry->registerPrompt($prompt, static fn () => []); - - $this->registry->unregisterPrompt('test_prompt'); - - $this->expectException(PromptNotFoundException::class); - $this->registry->getPrompt('test_prompt'); - } - - public function testRegisterToolHandlesStringHandler(): void - { - $tool = $this->createValidTool('test_tool'); - $handler = 'TestClass::testMethod'; - - $this->registry->registerTool($tool, $handler); - - $toolRef = $this->registry->getTool('test_tool'); - $this->assertEquals($handler, $toolRef->handler); - } - - public function testRegisterToolHandlesArrayHandler(): void - { - $tool = $this->createValidTool('test_tool'); - $handler = ['TestClass', 'testMethod']; - - $this->registry->registerTool($tool, $handler); - - $toolRef = $this->registry->getTool('test_tool'); - $this->assertEquals($handler, $toolRef->handler); - } - - public function testRegisterResourceHandlesCallableHandler(): void - { - $resource = $this->createValidResource('test://resource'); - $handler = static fn () => 'content'; - - $this->registry->registerResource($resource, $handler); - - $resourceRef = $this->registry->getResource('test://resource'); - $this->assertEquals($handler, $resourceRef->handler); - } - - public function testMultipleRegistrationsOfSameElementWithSameType(): void - { - $tool1 = $this->createValidTool('test_tool'); - $tool2 = $this->createValidTool('test_tool'); - - $this->registry->registerTool($tool1, static fn () => 'first'); - $this->registry->registerTool($tool2, static fn () => 'second'); - - // Second registration should override the first - $toolRef = $this->registry->getTool('test_tool'); - $this->assertEquals('second', ($toolRef->handler)()); - } - - public function testExtractStructuredContentReturnsNullWhenOutputSchemaIsNull(): void - { - $tool = $this->createValidTool('test_tool', null); - $this->registry->registerTool($tool, static fn () => 'result'); - - $toolRef = $this->registry->getTool('test_tool'); - $this->assertNull($toolRef->extractStructuredContent('result')); - } - - public function testExtractStructuredContentReturnsArrayMatchingSchema(): void - { - $tool = $this->createValidTool('test_tool', [ - 'type' => 'object', - 'properties' => [ - 'param' => ['type' => 'string'], - ], - 'required' => ['param'], - ]); - $this->registry->registerTool($tool, static fn () => [ - 'param' => 'test', - ]); - - $toolRef = $this->registry->getTool('test_tool'); - $this->assertEquals([ - 'param' => 'test', - ], $toolRef->extractStructuredContent([ - 'param' => 'test', - ])); - } - - public function testExtractStructuredContentReturnsArrayDirectlyForAdditionalProperties(): void - { - $tool = $this->createValidTool('test_tool', [ - 'type' => 'object', - 'additionalProperties' => true, - ]); - $this->registry->registerTool($tool, static fn () => ['success' => true, 'message' => 'done']); - - $toolRef = $this->registry->getTool('test_tool'); - $this->assertEquals(['success' => true, 'message' => 'done'], $toolRef->extractStructuredContent(['success' => true, 'message' => 'done'])); - } - - /** - * @dataProvider provideHandshakeVersions - */ - public function testExtractStructuredContentDropsListResultsBeforeSep2106(?ProtocolVersion $version): void - { - // Up to 2025-11-25 a PHP list serializes to something `structuredContent` - // does not allow — a JSON array — and `Tool::fromArray()` enforces the - // matching rule by rejecting any outputSchema whose type is not "object". - $outputSchema = [ - 'type' => 'object', - 'properties' => [ - 'foo' => ['type' => 'string'], - ], - 'required' => ['foo'], - ]; - - $tool = $this->createValidTool('list_static_data', $outputSchema); - $toolReturnValue = [ - ['foo' => 'bar'], - ['foo' => 'bar'], - ]; - - $this->registry->registerTool($tool, static fn () => $toolReturnValue); - - $toolRef = $this->registry->getTool('list_static_data'); - $this->assertNull($toolRef->extractStructuredContent($toolReturnValue, $version)); - } - - /** - * The revision is optional, and omitting it has to keep the strict rule: it is - * what every revision reachable through the `initialize` handshake requires. - * - * @return iterable - */ - public static function provideHandshakeVersions(): iterable - { - yield 'unspecified' => [null]; - - foreach (ProtocolVersion::handshakeVersions() as $version) { - yield $version->value => [$version]; - } - } - - public function testExtractStructuredContentKeepsListResultsFromSep2106On(): void - { - // SEP-2106 widened `structuredContent` to any JSON value conforming to - // `outputSchema`, and `outputSchema` to any JSON Schema 2020-12 — the spec's - // own example of a legal result is a list of records like this one. - $outputSchema = [ - 'type' => 'array', - 'items' => [ - 'type' => 'object', - 'properties' => ['foo' => ['type' => 'string']], - ], - ]; - - $tool = $this->createValidTool('list_static_data', $outputSchema); - $toolReturnValue = [ - ['foo' => 'bar'], - ['foo' => 'baz'], - ]; - - $this->registry->registerTool($tool, static fn () => $toolReturnValue); - - $toolRef = $this->registry->getTool('list_static_data'); - $this->assertSame($toolReturnValue, $toolRef->extractStructuredContent($toolReturnValue, ProtocolVersion::V2026_07_28)); - } - - public function testExtractStructuredContentDropsListOfScalarsBeforeSep2106(): void - { - $tool = $this->createValidTool('list_ids', null); - $toolReturnValue = ['101', '102', '103']; - - $this->registry->registerTool($tool, static fn () => $toolReturnValue); - - $toolRef = $this->registry->getTool('list_ids'); - $this->assertNull($toolRef->extractStructuredContent($toolReturnValue, ProtocolVersion::V2025_11_25)); - $this->assertSame($toolReturnValue, $toolRef->extractStructuredContent($toolReturnValue, ProtocolVersion::V2026_07_28)); - } - - public function testExtractStructuredContentEncodesObjectResults(): void - { - $tool = $this->createValidTool('describe_thing', null); - $toolReturnValue = new \stdClass(); - $toolReturnValue->id = 1; - $toolReturnValue->label = 'thing'; - - $this->registry->registerTool($tool, static fn () => $toolReturnValue); - - $toolRef = $this->registry->getTool('describe_thing'); - $this->assertSame(['id' => 1, 'label' => 'thing'], $toolRef->extractStructuredContent($toolReturnValue)); - } - - public function testExtractStructuredContentAppliesTheListRuleToObjectResultsToo(): void - { - // `JsonSerializable` can hand back a list just as a raw array result can, - // and it is no more — and no less — valid for having come from an object. - $tool = $this->createValidTool('list_things', null); - $toolReturnValue = new class implements \JsonSerializable { - public function jsonSerialize(): array - { - return [['id' => 1], ['id' => 2]]; - } - }; - - $this->registry->registerTool($tool, static fn () => $toolReturnValue); - - $toolRef = $this->registry->getTool('list_things'); - $this->assertNull($toolRef->extractStructuredContent($toolReturnValue, ProtocolVersion::V2025_11_25)); - $this->assertSame([['id' => 1], ['id' => 2]], $toolRef->extractStructuredContent($toolReturnValue, ProtocolVersion::V2026_07_28)); - } - - public function testExtractStructuredContentReturnsNullForObjectsSerializingToAScalar(): void - { - // SEP-2106 allows a scalar `structuredContent`, but `CallToolResult` types - // the field as `?array` and cannot carry one — so it is dropped in every - // revision until that type widens. - $tool = $this->createValidTool('count_things', null); - $toolReturnValue = new class implements \JsonSerializable { - public function jsonSerialize(): int - { - return 42; - } - }; - - $this->registry->registerTool($tool, static fn () => $toolReturnValue); - - $toolRef = $this->registry->getTool('count_things'); - $this->assertNull($toolRef->extractStructuredContent($toolReturnValue, ProtocolVersion::V2025_11_25)); - $this->assertNull($toolRef->extractStructuredContent($toolReturnValue, ProtocolVersion::V2026_07_28)); - } - - public function testExtractStructuredContentReturnsNullForArrayOfContentItems(): void - { - // Unlike the list rule, this one is revision-independent: the items are - // already carried in the result's `content`. - $tool = $this->createValidTool('lookup_thing', null); - $toolReturnValue = [ - new TextContent('Found it.'), - new ResourceLink(uri: 'thing://1', name: 'thing_1'), - ]; - - $this->registry->registerTool($tool, static fn () => $toolReturnValue); - - $toolRef = $this->registry->getTool('lookup_thing'); - $this->assertNull($toolRef->extractStructuredContent($toolReturnValue, ProtocolVersion::V2025_11_25)); - $this->assertNull($toolRef->extractStructuredContent($toolReturnValue, ProtocolVersion::V2026_07_28)); - } - - public function testConfiguredLoaderIsNotRunUntilFirstRead(): void - { - $loader = $this->createMock(LoaderInterface::class); - $loader->expects($this->never())->method('load'); - - // Constructing (and registering) must not trigger the loader. - $registry = new Registry(null, $this->logger, loader: $loader); - $registry->registerTool($this->createValidTool('manual'), 'handler'); - } - - public function testConfiguredLoaderRunsOnFirstReadAndPopulatesTheRegistry(): void - { - $loader = $this->toolLoader($this->createValidTool('loaded')); - $registry = new Registry(null, $this->logger, loader: $loader); - - $this->assertTrue($registry->hasTools()); - $this->assertArrayHasKey('loaded', $registry->getTools()->references); - } - - public function testConfiguredLoaderRunsExactlyOnceAcrossManyReads(): void - { - $loader = $this->createMock(LoaderInterface::class); - $loader->expects($this->once())->method('load'); - - $registry = new Registry(null, $this->logger, loader: $loader); - $registry->hasTools(); - $registry->getTools(); - $registry->hasResources(); - $registry->getPrompts(); - } - - public function testRuntimeRegistrationsSurviveTheDeferredLoad(): void - { - $loader = $this->toolLoader($this->createValidTool('loaded')); - $registry = new Registry(null, $this->logger, loader: $loader); - // Registered before the first read; the deferred load must be additive, not replacing. - $registry->registerTool($this->createValidTool('runtime'), 'handler'); - - $tools = $registry->getTools()->references; - $this->assertArrayHasKey('runtime', $tools); - $this->assertArrayHasKey('loaded', $tools); - } - - public function testConfiguredLoaderRetriesAfterAFailedLoad(): void - { - $tool = $this->createValidTool('loaded'); - $loader = new class($tool) implements LoaderInterface { - private int $calls = 0; - - public function __construct(private readonly Tool $tool) - { - } - - public function load(RegistryInterface $registry): void - { - ++$this->calls; - if (1 === $this->calls) { - throw new \RuntimeException('data source not ready'); - } - - $registry->registerTool($this->tool, 'handler'); - } - }; - - $registry = new Registry(null, $this->logger, loader: $loader); - - try { - $registry->hasTools(); - $this->fail('Expected the first load to throw.'); - } catch (\RuntimeException $e) { - $this->assertSame('data source not ready', $e->getMessage()); - } - - $this->assertArrayHasKey('loaded', $registry->getTools()->references); - } - - public function testLoadRunsTheConfiguredLoaderEagerly(): void - { - $loader = $this->createMock(LoaderInterface::class); - $loader->expects($this->once())->method('load'); - - $registry = new Registry(null, $this->logger, loader: $loader); - $registry->load(); - } - - public function testLoadIsANoopWithoutAConfiguredLoader(): void - { - $registry = new Registry(null, $this->logger); - $registry->load(); - - $this->assertFalse($registry->hasTools()); - } - - private function toolLoader(Tool $tool): LoaderInterface - { - return new class($tool) implements LoaderInterface { - public function __construct(private readonly Tool $tool) - { - } - - public function load(RegistryInterface $registry): void - { - $registry->registerTool($this->tool, 'handler'); - } - }; - } - - private function createValidTool(string $name, ?array $outputSchema = null): Tool - { - return new Tool( - name: $name, - title: null, - inputSchema: [ - 'type' => 'object', - 'properties' => [ - 'param' => ['type' => 'string'], - ], - 'required' => null, - ], - description: "Test tool: {$name}", - annotations: null, - icons: null, - meta: null, - outputSchema: $outputSchema - ); - } - - private function createValidResource(string $uri): ResourceDefinition - { - return new ResourceDefinition( - uri: $uri, - name: 'test_resource', - description: 'Test resource', - mimeType: 'text/plain', - ); - } - - private function createValidResourceTemplate(string $uriTemplate): ResourceTemplate - { - return new ResourceTemplate( - uriTemplate: $uriTemplate, - name: 'test_template', - description: 'Test resource template', - mimeType: 'text/plain', - ); - } - - private function createValidPrompt(string $name): Prompt - { - return new Prompt( - name: $name, - description: "Test prompt: {$name}", - arguments: [], - ); - } -} diff --git a/tests/Unit/Capability/Tool/NameValidatorTest.php b/tests/Unit/Capability/Tool/NameValidatorTest.php deleted file mode 100644 index 269be890..00000000 --- a/tests/Unit/Capability/Tool/NameValidatorTest.php +++ /dev/null @@ -1,56 +0,0 @@ -assertTrue((new NameValidator())->isValid($name)); - } - - public static function provideValidNames(): array - { - return [ - ['my_tool'], - ['MyTool123'], - ['my.tool'], - ['my-tool'], - ['my/tool'], - ['my_tool-01.02'], - ['my_long_toolname_that_is_exactly_sixty_four_characters_long_1234'], - ]; - } - - #[DataProvider('provideInvalidNames')] - public function testInvalidNames(string $name): void - { - $this->assertFalse((new NameValidator())->isValid($name)); - } - - public static function provideInvalidNames(): array - { - return [ - [''], - ['my tool'], - ['my@tool'], - ['my!tool'], - ['my_tool#1'], - ['this_tool_name_is_way_too_long_because_it_exceeds_the_sixty_four_character_limit_set_by_the_validator'], - ]; - } -} diff --git a/tests/Unit/Client/ClientTest.php b/tests/Unit/Client/ClientTest.php deleted file mode 100644 index 29d23ffe..00000000 --- a/tests/Unit/Client/ClientTest.php +++ /dev/null @@ -1,79 +0,0 @@ -createMock(TransportInterface::class); - $transport->method('send')->willReturnCallback(static function (string $data) use (&$sent): void { - $sent[] = $data; - }); - // Stand in for a completed initialize handshake, which the transport drives. - $transport->method('setState')->willReturnCallback(static function (ClientStateInterface $state): void { - $state->setInitialized(true); - }); - - $client = Client::builder() - ->setClientInfo('Roots Test', '1.0.0') - ->setCapabilities(new ClientCapabilities(roots: true, rootsListChanged: true)) - ->build(); - - $client->connect($transport); - $client->sendRootsListChanged(); - - $this->assertCount(1, $sent); - $decoded = json_decode($sent[0], true); - $this->assertSame('notifications/roots/list_changed', $decoded['method']); - } - - public function testSendRootsListChangedThrowsWhenCapabilityNotDeclared(): void - { - $transport = $this->createMock(TransportInterface::class); - $transport->expects($this->never())->method('send'); - - $client = Client::builder() - ->setClientInfo('Roots Test', '1.0.0') - ->setCapabilities(new ClientCapabilities(roots: true)) - ->build(); - - $client->connect($transport); - - $this->expectException(RuntimeException::class); - $this->expectExceptionMessage('roots.listChanged'); - - $client->sendRootsListChanged(); - } - - public function testSendRootsListChangedThrowsWhenNotConnected(): void - { - $client = Client::builder() - ->setClientInfo('Roots Test', '1.0.0') - ->setCapabilities(new ClientCapabilities(roots: true, rootsListChanged: true)) - ->build(); - - $this->expectException(ConnectionException::class); - $this->expectExceptionMessage('Client is not connected.'); - - $client->sendRootsListChanged(); - } -} diff --git a/tests/Unit/Client/ConfigurationTest.php b/tests/Unit/Client/ConfigurationTest.php deleted file mode 100644 index ae50a499..00000000 --- a/tests/Unit/Client/ConfigurationTest.php +++ /dev/null @@ -1,92 +0,0 @@ -expectException(InvalidArgumentException::class); - $this->expectExceptionMessage(\sprintf('The initialization timeout must be a positive number of seconds, got %d.', $seconds)); - - $this->createConfiguration(initTimeout: $seconds); - } - - #[TestDox('a non-positive request timeout of $seconds seconds is rejected')] - #[DataProvider('provideNonPositiveTimeouts')] - public function testNonPositiveRequestTimeoutIsRejected(int $seconds): void - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage(\sprintf('The request timeout must be a positive number of seconds, got %d.', $seconds)); - - $this->createConfiguration(requestTimeout: $seconds); - } - - /** - * @return iterable - */ - public static function provideNonPositiveTimeouts(): iterable - { - yield 'zero' => [0]; - yield 'negative' => [-1]; - } - - #[TestDox('the builder rejects a non-positive initialization timeout')] - public function testBuilderRejectsNonPositiveInitTimeout(): void - { - $builder = Client::builder()->setInitTimeout(0); - - $this->expectException(InvalidArgumentException::class); - - $builder->build(); - } - - #[TestDox('the builder rejects a non-positive request timeout')] - public function testBuilderRejectsNonPositiveRequestTimeout(): void - { - $builder = Client::builder()->setRequestTimeout(-5); - - $this->expectException(InvalidArgumentException::class); - - $builder->build(); - } - - #[TestDox('positive timeouts are accepted')] - public function testPositiveTimeoutsAreAccepted(): void - { - $config = $this->createConfiguration(initTimeout: 1, requestTimeout: 1); - - $this->assertSame(1, $config->initTimeout); - $this->assertSame(1, $config->requestTimeout); - } - - private function createConfiguration(int $initTimeout = 30, int $requestTimeout = 120): Configuration - { - return new Configuration( - clientInfo: new Implementation('test-client', '1.0.0'), - capabilities: new ClientCapabilities(), - initTimeout: $initTimeout, - requestTimeout: $requestTimeout, - ); - } -} diff --git a/tests/Unit/Client/Handler/Request/ElicitationRequestHandlerTest.php b/tests/Unit/Client/Handler/Request/ElicitationRequestHandlerTest.php deleted file mode 100644 index 67358717..00000000 --- a/tests/Unit/Client/Handler/Request/ElicitationRequestHandlerTest.php +++ /dev/null @@ -1,138 +0,0 @@ -callbackReturning( - new ElicitResult(ElicitAction::Decline), - )); - - $this->assertTrue($handler->supports($this->createElicitRequest())); - } - - public function testDoesNotSupportOtherRequests(): void - { - $handler = new ElicitationRequestHandler($this->callbackReturning( - new ElicitResult(ElicitAction::Decline), - )); - - $ping = PingRequest::fromArray([ - 'jsonrpc' => '2.0', - 'method' => PingRequest::getMethod(), - 'id' => 'ping-1', - ]); - - $this->assertFalse($handler->supports($ping)); - } - - public function testHandleReturnsResponseOnAccept(): void - { - $result = new ElicitResult(ElicitAction::Accept, ['name' => 'Ada']); - $handler = new ElicitationRequestHandler($this->callbackReturning($result)); - - $request = $this->createElicitRequest(); - $response = $handler->handle($request); - - $this->assertInstanceOf(Response::class, $response); - $this->assertSame($request->getId(), $response->id); - $this->assertSame($result, $response->result); - } - - public function testHandleReturnsErrorOnElicitationException(): void - { - $handler = new ElicitationRequestHandler($this->callbackThrowing( - new ElicitationException('user input unavailable'), - )); - - $request = $this->createElicitRequest(); - $response = $handler->handle($request); - - $this->assertInstanceOf(Error::class, $response); - $this->assertSame($request->getId(), $response->getId()); - $this->assertSame('user input unavailable', $response->message); - } - - public function testHandleReturnsGenericErrorOnThrowable(): void - { - $handler = new ElicitationRequestHandler($this->callbackThrowing( - new \RuntimeException('boom'), - )); - - $request = $this->createElicitRequest(); - $response = $handler->handle($request); - - $this->assertInstanceOf(Error::class, $response); - $this->assertSame($request->getId(), $response->getId()); - $this->assertSame('Error while processing elicitation', $response->message); - } - - private function createElicitRequest(): ElicitRequest - { - return ElicitRequest::fromArray([ - 'jsonrpc' => '2.0', - 'method' => ElicitRequest::getMethod(), - 'id' => 'elicit-'.uniqid(), - 'params' => [ - 'message' => 'Please provide your name.', - 'requestedSchema' => [ - 'type' => 'object', - 'properties' => [ - 'name' => ['type' => 'string', 'title' => 'Name'], - ], - 'required' => ['name'], - ], - ], - ]); - } - - private function callbackReturning(ElicitResult $result): ElicitationCallbackInterface - { - return new class($result) implements ElicitationCallbackInterface { - public function __construct(private readonly ElicitResult $result) - { - } - - public function __invoke(ElicitRequest $request): ElicitResult - { - return $this->result; - } - }; - } - - private function callbackThrowing(\Throwable $exception): ElicitationCallbackInterface - { - return new class($exception) implements ElicitationCallbackInterface { - public function __construct(private readonly \Throwable $exception) - { - } - - public function __invoke(ElicitRequest $request): ElicitResult - { - throw $this->exception; - } - }; - } -} diff --git a/tests/Unit/Client/Handler/Request/ListRootsRequestHandlerTest.php b/tests/Unit/Client/Handler/Request/ListRootsRequestHandlerTest.php deleted file mode 100644 index c101b168..00000000 --- a/tests/Unit/Client/Handler/Request/ListRootsRequestHandlerTest.php +++ /dev/null @@ -1,105 +0,0 @@ -createCallback(new ListRootsResult([]))); - - $this->assertTrue($handler->supports(new ListRootsRequest())); - } - - public function testDoesNotSupportOtherRequests(): void - { - $handler = new ListRootsRequestHandler($this->createCallback(new ListRootsResult([]))); - - $this->assertFalse($handler->supports(new PingRequest())); - } - - public function testHandleReturnsRootsFromCallback(): void - { - $result = new ListRootsResult([ - new Root('file:///home/user/project', 'project'), - new Root('file:///tmp'), - ]); - - $handler = new ListRootsRequestHandler($this->createCallback($result)); - - $request = (new ListRootsRequest())->withId('req-1'); - $response = $handler->handle($request); - - $this->assertInstanceOf(Response::class, $response); - $this->assertSame('req-1', $response->getId()); - $this->assertSame($result, $response->result); - } - - public function testHandleReturnsErrorWhenCallbackThrows(): void - { - $handler = new ListRootsRequestHandler(new class implements RootsCallbackInterface { - public function __invoke(ListRootsRequest $request): ListRootsResult - { - throw new \RuntimeException('boom'); - } - }); - - $request = (new ListRootsRequest())->withId('req-2'); - $response = $handler->handle($request); - - $this->assertInstanceOf(Error::class, $response); - $this->assertSame('req-2', $response->getId()); - $this->assertSame('Error while listing roots', $response->message); - } - - public function testHandleForwardsRootsExceptionMessage(): void - { - $handler = new ListRootsRequestHandler(new class implements RootsCallbackInterface { - public function __invoke(ListRootsRequest $request): ListRootsResult - { - throw new RootsException('permission denied'); - } - }); - - $request = (new ListRootsRequest())->withId('req-3'); - $response = $handler->handle($request); - - $this->assertInstanceOf(Error::class, $response); - $this->assertSame('req-3', $response->getId()); - $this->assertSame('permission denied', $response->message); - } - - private function createCallback(ListRootsResult $result): RootsCallbackInterface - { - return new class($result) implements RootsCallbackInterface { - public function __construct(private readonly ListRootsResult $result) - { - } - - public function __invoke(ListRootsRequest $request): ListRootsResult - { - return $this->result; - } - }; - } -} diff --git a/tests/Unit/Client/Handler/Request/SamplingRequestHandlerTest.php b/tests/Unit/Client/Handler/Request/SamplingRequestHandlerTest.php deleted file mode 100644 index 923d6692..00000000 --- a/tests/Unit/Client/Handler/Request/SamplingRequestHandlerTest.php +++ /dev/null @@ -1,114 +0,0 @@ -callbackReturningText()); - - $request = $this->requestFor([ - new SamplingMessage(Role::User, new TextContent('Weather in Paris?')), - new SamplingMessage(Role::Assistant, new ToolUseContent('call-1', 'weather', ['city' => 'Paris'])), - new SamplingMessage(Role::User, new ToolResultContent('call-1', [new TextContent('18 C')])), - ]); - - $response = $handler->handle($request); - - $this->assertInstanceOf(Response::class, $response); - $this->assertSame($request->getId(), $response->id); - } - - /** - * The spec asks for -32602 on both of these, and the callback must not run. - * - * @return iterable - */ - public static function provideToolFlowViolations(): iterable - { - yield 'tool results mixed with other content' => [ - [ - new SamplingMessage(Role::Assistant, new ToolUseContent('call-1', 'weather', [])), - new SamplingMessage(Role::User, [ - new ToolResultContent('call-1', [new TextContent('18 C')]), - new TextContent('and also...'), - ]), - ], - 'Tool results mixed with other content.', - ]; - - yield 'tool result missing' => [ - [new SamplingMessage(Role::Assistant, new ToolUseContent('call-1', 'weather', []))], - 'Tool result missing in request.', - ]; - } - - /** - * @param SamplingMessage[] $messages - */ - #[DataProvider('provideToolFlowViolations')] - public function testHandleRejectsToolFlowViolationsWithInvalidParams(array $messages, string $expectedMessage): void - { - $callback = new class implements SamplingCallbackInterface { - public bool $invoked = false; - - public function __invoke(CreateSamplingMessageRequest $request): CreateSamplingMessageResult - { - $this->invoked = true; - - throw new \LogicException('The callback must not run for an invalid request.'); - } - }; - - $request = $this->requestFor($messages); - $response = (new SamplingRequestHandler($callback))->handle($request); - - $this->assertInstanceOf(Error::class, $response); - $this->assertSame(Error::INVALID_PARAMS, $response->code); - $this->assertSame($expectedMessage, $response->message); - $this->assertSame($request->getId(), $response->id); - $this->assertFalse($callback->invoked); - } - - /** - * @param SamplingMessage[] $messages - */ - private function requestFor(array $messages): CreateSamplingMessageRequest - { - return (new CreateSamplingMessageRequest($messages, 150))->withId('sampling-1'); - } - - private function callbackReturningText(): SamplingCallbackInterface - { - return new class implements SamplingCallbackInterface { - public function __invoke(CreateSamplingMessageRequest $request): CreateSamplingMessageResult - { - return new CreateSamplingMessageResult(Role::Assistant, new TextContent('Paris is warm.'), 'test-model'); - } - }; - } -} diff --git a/tests/Unit/Client/ProtocolTest.php b/tests/Unit/Client/ProtocolTest.php deleted file mode 100644 index 7545f739..00000000 --- a/tests/Unit/Client/ProtocolTest.php +++ /dev/null @@ -1,202 +0,0 @@ -value); - $protocol = new Protocol(); - $protocol->connect($transport, $config = $this->createConfiguration(ProtocolVersion::V2025_06_18)); - - $protocol->initialize($config); - - $this->assertSame(ProtocolVersion::V2025_06_18->value, $transport->offeredVersion); - } - - #[TestDox('never offers a modern version over the initialize handshake, and warns about it')] - public function testDoesNotOfferModernVersionOverHandshake(): void - { - $transport = new RecordingTransport(ProtocolVersion::latestHandshake()->value); - $protocol = new Protocol(logger: $logger = new CollectingLogger()); - $protocol->connect($transport, $config = $this->createConfiguration(ProtocolVersion::V2026_07_28)); - - $protocol->initialize($config); - - $this->assertSame(ProtocolVersion::latestHandshake()->value, $transport->offeredVersion); - $this->assertSame([[ - 'configured' => ProtocolVersion::V2026_07_28->value, - 'offered' => ProtocolVersion::latestHandshake()->value, - ]], $logger->warnings); - } - - #[TestDox('accepts a counter-offer the SDK can speak and records it as negotiated')] - public function testAcceptsHandshakeCounterOffer(): void - { - $transport = new RecordingTransport(ProtocolVersion::V2024_11_05->value); - $protocol = new Protocol(); - $protocol->connect($transport, $config = $this->createConfiguration(ProtocolVersion::V2025_11_25)); - - $result = $protocol->initialize($config); - - $this->assertInstanceOf(Response::class, $result); - $this->assertSame(ProtocolVersion::V2024_11_05, $protocol->getState()->getProtocolVersion()); - $this->assertTrue($protocol->getState()->isInitialized()); - } - - #[TestDox('fails the handshake when the server answers with a version the SDK cannot speak')] - #[DataProvider('provideUnusableCounterOffers')] - public function testRejectsUnusableCounterOffer(string $counterOffer): void - { - $transport = new RecordingTransport($counterOffer); - $protocol = new Protocol(); - $protocol->connect($transport, $config = $this->createConfiguration(ProtocolVersion::V2025_11_25)); - - $result = $protocol->initialize($config); - - $this->assertInstanceOf(Error::class, $result); - $this->assertStringContainsString($counterOffer, $result->message); - $this->assertNull($protocol->getState()->getProtocolVersion()); - $this->assertFalse($protocol->getState()->isInitialized()); - } - - /** - * @return iterable - */ - public static function provideUnusableCounterOffers(): iterable - { - yield 'unknown revision' => ['2099-01-01']; - // The modern era has no `initialize`, so a server answering the handshake - // with one has produced a connection neither side can actually use. - yield 'modern revision' => [ProtocolVersion::V2026_07_28->value]; - } - - private function createConfiguration(ProtocolVersion $protocolVersion): Configuration - { - return new Configuration( - clientInfo: new Implementation('client-app', '1.0.0'), - capabilities: new ClientCapabilities(), - protocolVersion: $protocolVersion, - ); - } -} - -/** - * Transport that answers the `initialize` request inline with a canned - * `protocolVersion`, so the handshake resolves without a Fiber round-trip. - */ -final class RecordingTransport implements TransportInterface -{ - public ?string $offeredVersion = null; - - private ClientStateInterface $state; - - public function __construct(private readonly string $counterOffer) - { - } - - public function send(string $data): void - { - /** @var array{id: int|string, method: string, params?: array{protocolVersion?: string}} $message */ - $message = json_decode($data, true); - - if ('initialize' !== ($message['method'] ?? null)) { - return; - } - - $this->offeredVersion = $message['params']['protocolVersion'] ?? null; - - $this->state->storeResponse($message['id'], [ - 'jsonrpc' => MessageInterface::JSONRPC_VERSION, - 'id' => $message['id'], - 'result' => [ - 'protocolVersion' => $this->counterOffer, - 'capabilities' => [], - 'serverInfo' => ['name' => 'server', 'version' => '1.2.3'], - ], - ]); - } - - public function setState(ClientStateInterface $state): void - { - $this->state = $state; - } - - public function connect(): void - { - } - - public function close(): void - { - } - - public function runRequest(\Fiber $fiber, ?callable $onProgress = null): Response|Error - { - throw new LogicException('Not used in these tests.'); - } - - public function onInitialize(callable $callback): void - { - } - - public function onMessage(callable $callback): void - { - } - - public function onError(callable $callback): void - { - } - - public function onClose(callable $callback): void - { - } -} - -/** - * Logger that keeps the context of every warning, so a silent fallback can be - * told apart from one the caller was told about. - */ -final class CollectingLogger extends AbstractLogger -{ - /** @var list> */ - public array $warnings = []; - - /** - * @param string|\Stringable $message - * @param array $context - */ - public function log($level, $message, array $context = []): void - { - if (LogLevel::WARNING === $level) { - $this->warnings[] = $context; - } - } -} diff --git a/tests/Unit/Client/Transport/HttpTransportTest.php b/tests/Unit/Client/Transport/HttpTransportTest.php deleted file mode 100644 index 6c6119e1..00000000 --- a/tests/Unit/Client/Transport/HttpTransportTest.php +++ /dev/null @@ -1,181 +0,0 @@ -factory = new Psr17Factory(); - } - - /** - * @return iterable - */ - public static function frameProvider(): iterable - { - yield 'LF line endings' => ["event: message\ndata: %s\n\n"]; - // sse-starlette (and therefore every MCP Python SDK server) defaults to CRLF. - yield 'CRLF line endings' => ["event: message\r\ndata: %s\r\n\r\n"]; - yield 'CR line endings' => ["event: message\rdata: %s\r\r"]; - yield 'with an id field' => ["id: 1\r\nevent: message\r\ndata: %s\r\n\r\n"]; - yield 'no trailing blank line' => ["event: message\ndata: %s\n"]; - yield 'preceded by a comment' => [": ping\n\nevent: message\ndata: %s\n\n"]; - } - - #[DataProvider('frameProvider')] - #[TestDox('initialization succeeds for an SSE response framed as: $_dataName')] - public function testInitializeParsesSseFraming(string $frame): void - { - $httpClient = new class($frame) implements ClientInterface { - public function __construct(private readonly string $frame) - { - } - - public function sendRequest(RequestInterface $request): ResponseInterface - { - $decoded = json_decode((string) $request->getBody(), true); - - if ('initialize' !== ($decoded['method'] ?? null)) { - return new Response(202); - } - - $payload = json_encode([ - 'jsonrpc' => '2.0', - 'id' => $decoded['id'], - 'result' => [ - 'protocolVersion' => '2025-11-25', - 'capabilities' => ['tools' => ['listChanged' => false]], - 'serverInfo' => ['name' => 'test-server', 'version' => '1.0.0'], - ], - ]); - - return new Response(200, [ - 'Content-Type' => 'text/event-stream', - 'Mcp-Session-Id' => 'abc123', - ], \sprintf($this->frame, $payload)); - } - }; - - $client = Client::builder() - ->setClientInfo('test-client', '1.0.0') - ->setInitTimeout(1) - ->build(); - - $client->connect(new HttpTransport('http://localhost/mcp', [], $httpClient, $this->factory, $this->factory)); - - $this->assertTrue($client->isConnected()); - $this->assertSame('test-server', $client->getServerInfo()?->name); - } - - #[TestDox('SSE stream is aborted before the buffer can exceed the configured cap')] - public function testSseBufferIsBoundedByConfiguredCap(): void - { - $transport = $this->createTransport(maxSseBufferBytes: 64); - $state = new ClientState(); - $state->addPendingRequest(1, 30); - $transport->setState($state); - - // A server that streams data without ever sending the "\n\n" delimiter. - $this->setActiveStream($transport, $this->factory->createStream(str_repeat('a', 4096))); - - $this->invokeProcessSseStream($transport); - - $this->assertSame('', $this->readPrivate($transport, 'sseBuffer'), 'buffer must be cleared on abort'); - $this->assertNull($this->readPrivate($transport, 'activeStream'), 'stream must be released on abort'); - } - - #[TestDox('aborting the SSE stream fails the in-flight request immediately instead of waiting for its timeout')] - public function testAbortFailsPendingRequestFast(): void - { - $transport = $this->createTransport(maxSseBufferBytes: 64); - $state = new ClientState(); - $state->addPendingRequest(1, 30); - $transport->setState($state); - - $this->setActiveStream($transport, $this->factory->createStream(str_repeat('a', 4096))); - - $this->invokeProcessSseStream($transport); - - $response = $state->consumeResponse(1); - $this->assertInstanceOf(Error::class, $response); - $this->assertSame(Error::INTERNAL_ERROR, $response->code); - $this->assertSame(1, $response->id); - } - - #[TestDox('well-formed delimited events are parsed and dispatched')] - public function testWellFormedEventsStillParse(): void - { - $transport = $this->createTransport(); - $messages = []; - $transport->onMessage(static function (string $message) use (&$messages): void { - $messages[] = $message; - }); - - $this->setActiveStream($transport, $this->factory->createStream("data: hello\n\ndata: world\n\n")); - - $this->invokeProcessSseStream($transport); - - $this->assertSame(['hello', 'world'], $messages); - } - - #[TestDox('the buffer cap must be a positive number of bytes')] - public function testRejectsNonPositiveCap(): void - { - $this->expectException(InvalidArgumentException::class); - - $this->createTransport(maxSseBufferBytes: 0); - } - - private function createTransport(int $maxSseBufferBytes = 8 * 1024 * 1024): HttpTransport - { - return new HttpTransport( - endpoint: 'https://example.test/mcp', - httpClient: $this->createMock(ClientInterface::class), - requestFactory: $this->factory, - streamFactory: $this->factory, - maxSseBufferBytes: $maxSseBufferBytes, - ); - } - - private function setActiveStream(HttpTransport $transport, StreamInterface $stream): void - { - (new \ReflectionProperty($transport, 'activeStream'))->setValue($transport, $stream); - } - - private function invokeProcessSseStream(HttpTransport $transport): void - { - (new \ReflectionMethod($transport, 'processSSEStream'))->invoke($transport); - } - - private function readPrivate(HttpTransport $transport, string $property): mixed - { - return (new \ReflectionProperty($transport, $property))->getValue($transport); - } -} diff --git a/tests/Unit/Client/Transport/StdioTransportTest.php b/tests/Unit/Client/Transport/StdioTransportTest.php deleted file mode 100644 index fb314083..00000000 --- a/tests/Unit/Client/Transport/StdioTransportTest.php +++ /dev/null @@ -1,107 +0,0 @@ -addPendingRequest(1, 30); - $transport->setState($state); - - // A server that floods stdout without ever emitting a newline. - $this->setStdout($transport, $this->stream(str_repeat('a', 8192))); - - $this->invokeProcessInput($transport); - - $this->assertSame('', $this->readPrivate($transport, 'inputBuffer'), 'buffer must be cleared on abort'); - } - - #[TestDox('aborting the input fails the in-flight request immediately')] - public function testAbortFailsPendingRequestFast(): void - { - $transport = new StdioTransport(command: 'true', maxBufferSize: 64); - $state = new ClientState(); - $state->addPendingRequest(1, 30); - $transport->setState($state); - - $this->setStdout($transport, $this->stream(str_repeat('a', 8192))); - - $this->invokeProcessInput($transport); - - $response = $state->consumeResponse(1); - $this->assertInstanceOf(Error::class, $response); - $this->assertSame(Error::INTERNAL_ERROR, $response->code); - $this->assertSame(1, $response->id); - } - - #[TestDox('newline-delimited frames within the cap are parsed and dispatched')] - public function testWellFormedFramesStillParse(): void - { - $transport = new StdioTransport(command: 'true'); - $messages = []; - $transport->onMessage(static function (string $message) use (&$messages): void { - $messages[] = $message; - }); - - $this->setStdout($transport, $this->stream('{"a":1}'."\n".'{"b":2}'."\n")); - - $this->invokeProcessInput($transport); - - $this->assertSame(['{"a":1}', '{"b":2}'], $messages); - } - - #[TestDox('the buffer cap must be a positive number of bytes')] - public function testRejectsNonPositiveCap(): void - { - $this->expectException(InvalidArgumentException::class); - - new StdioTransport(command: 'true', maxBufferSize: 0); - } - - /** - * @return resource - */ - private function stream(string $contents) - { - $stream = fopen('php://temp', 'r+'); - fwrite($stream, $contents); - rewind($stream); - - return $stream; - } - - private function setStdout(StdioTransport $transport, mixed $stream): void - { - (new \ReflectionProperty($transport, 'stdout'))->setValue($transport, $stream); - } - - private function invokeProcessInput(StdioTransport $transport): void - { - (new \ReflectionMethod($transport, 'processInput'))->invoke($transport); - } - - private function readPrivate(StdioTransport $transport, string $property): mixed - { - return (new \ReflectionProperty($transport, $property))->getValue($transport); - } -} diff --git a/tests/Unit/ClientTest.php b/tests/Unit/ClientTest.php deleted file mode 100644 index 558de954..00000000 --- a/tests/Unit/ClientTest.php +++ /dev/null @@ -1,283 +0,0 @@ -assertInstanceOf(Builder::class, Client::builder()); - } - - #[TestDox('connect() succeeds on the first attempt without retrying')] - public function testConnectSucceedsWithoutRetrying(): void - { - $transport = new FakeTransport([FakeTransport::ACCEPT]); - - $client = Client::builder()->build(); - $client->connect($transport); - - $this->assertSame(1, $transport->connectCalls); - $this->assertSame(0, $transport->closeCalls); - $this->assertTrue($client->isConnected()); - } - - #[TestDox('connect() retries a failed attempt and succeeds on a later one')] - public function testConnectRetriesUntilItSucceeds(): void - { - $transport = new FakeTransport([FakeTransport::REJECT, FakeTransport::REJECT, FakeTransport::ACCEPT]); - - $client = Client::builder()->setMaxRetries(3)->build(); - $client->connect($transport); - - $this->assertSame(3, $transport->connectCalls); - $this->assertTrue($client->isConnected()); - $this->assertSame('Test Server', $client->getServerInfo()?->name); - } - - #[TestDox('connect() closes the transport between two attempts')] - public function testConnectClosesTransportBetweenAttempts(): void - { - $transport = new FakeTransport([FakeTransport::REJECT, FakeTransport::ACCEPT]); - - $client = Client::builder()->setMaxRetries(1)->build(); - $client->connect($transport); - - $this->assertSame(2, $transport->connectCalls); - $this->assertSame(1, $transport->closeCalls, 'the failed attempt must be cleaned up before the retry'); - } - - #[TestDox('connect() rethrows once the retries are exhausted')] - public function testConnectThrowsWhenRetriesAreExhausted(): void - { - $transport = new FakeTransport([FakeTransport::REJECT, FakeTransport::REJECT, FakeTransport::REJECT]); - - $client = Client::builder()->setMaxRetries(2)->build(); - - try { - $client->connect($transport); - $this->fail(\sprintf('Expected a "%s" to be thrown.', ConnectionException::class)); - } catch (ConnectionException) { - // Expected. - } - - $this->assertSame(3, $transport->connectCalls, 'one initial attempt plus two retries'); - $this->assertSame(3, $transport->closeCalls, 'the last attempt must be cleaned up as well'); - $this->assertFalse($client->isConnected()); - } - - #[TestDox('setMaxRetries(0) disables retrying')] - public function testZeroRetriesAttemptsToConnectOnce(): void - { - $transport = new FakeTransport([FakeTransport::REJECT, FakeTransport::ACCEPT]); - - $client = Client::builder()->setMaxRetries(0)->build(); - - $this->expectException(ConnectionException::class); - - try { - $client->connect($transport); - } finally { - $this->assertSame(1, $transport->connectCalls); - } - } - - #[TestDox('a negative retry count is rejected')] - public function testNegativeRetryCountIsRejected(): void - { - $builder = Client::builder()->setMaxRetries(-1); - - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('The maximum number of retries must be zero or greater, got -1.'); - - $builder->build(); - } - - #[TestDox('a connection breaking after the handshake result does not leave the client connected')] - public function testFailureAfterHandshakeResultDoesNotLeaveClientConnected(): void - { - $transport = new FakeTransport([FakeTransport::BREAK_AFTER_ACCEPT]); - - $client = Client::builder()->setMaxRetries(0)->build(); - - try { - $client->connect($transport); - $this->fail(\sprintf('Expected a "%s" to be thrown.', ConnectionException::class)); - } catch (ConnectionException) { - // Expected. - } - - $this->assertFalse($client->isConnected()); - } - - #[TestDox('a timed out attempt does not leave state behind that fails the retry')] - public function testTimedOutAttemptDoesNotPoisonTheRetry(): void - { - // The shortest allowed init timeout, so this waits out about a second. - $transport = new FakeTransport([FakeTransport::IGNORE, FakeTransport::ACCEPT]); - - $client = Client::builder()->setInitTimeout(1)->setMaxRetries(1)->build(); - $client->connect($transport); - - $this->assertSame(2, $transport->connectCalls); - $this->assertTrue($client->isConnected()); - } -} - -/** - * Transport whose connection attempts succeed or fail on command. - * - * Requests are answered from the polling loop rather than from send(), the way - * a stdio transport does, with each connect() call answering according to the - * next configured outcome. - * - * @phpstan-import-type McpFiber from TransportInterface - */ -final class FakeTransport extends BaseTransport -{ - /** The initialize request is answered with a result. */ - public const ACCEPT = 'accept'; - - /** The initialize request is answered with a JSON-RPC error. */ - public const REJECT = 'reject'; - - /** The initialize request is not answered at all and has to time out. */ - public const IGNORE = 'ignore'; - - /** The initialize request is answered, but the connection breaks right after. */ - public const BREAK_AFTER_ACCEPT = 'break_after_accept'; - - public int $connectCalls = 0; - public int $closeCalls = 0; - - private string $outcome = self::ACCEPT; - - /** @var list Answers not yet delivered to the client */ - private array $outbox = []; - - /** - * @param list $attempts How each successive connect() call behaves - */ - public function __construct(private array $attempts = [self::ACCEPT]) - { - parent::__construct(); - } - - public function connect(): void - { - ++$this->connectCalls; - $this->outcome = array_shift($this->attempts) ?? self::ACCEPT; - - $result = $this->runRequest(new \Fiber(fn () => $this->handleInitialize())); - - if ($result instanceof Error) { - throw new ConnectionException('Initialization failed: '.$result->message); - } - } - - public function send(string $data): void - { - if (self::IGNORE === $this->outcome) { - return; - } - - $message = json_decode($data, true, 512, \JSON_THROW_ON_ERROR); - - if (!isset($message['id'])) { - if (self::BREAK_AFTER_ACCEPT === $this->outcome) { - throw new ConnectionException('Connection lost'); - } - - return; // A notification, nothing to answer. - } - - $answer = self::REJECT === $this->outcome - ? ['error' => ['code' => Error::INTERNAL_ERROR, 'message' => 'Server unavailable']] - : ['result' => [ - 'protocolVersion' => ProtocolVersion::V2025_11_25->value, - 'capabilities' => [], - 'serverInfo' => ['name' => 'Test Server', 'version' => '1.0.0'], - ]]; - - $this->outbox[] = json_encode(['jsonrpc' => '2.0', 'id' => $message['id']] + $answer, \JSON_THROW_ON_ERROR); - } - - public function runRequest(\Fiber $fiber, ?callable $onProgress = null): Response|Error - { - $fiber->start(); - - // Polls at the same 1ms interval as the real transports, so a request - // left unanswered runs into its timeout in real time. - for ($poll = 0; !$fiber->isTerminated(); ++$poll) { - if ($poll > 5000) { - throw new \LogicException('The fiber never terminated, no pending request became resolvable.'); - } - - foreach ($this->outbox as $answer) { - $this->handleMessage($answer); - } - $this->outbox = []; - - $this->resumeFiber($fiber); - - usleep(1000); - } - - return $fiber->getReturn(); - } - - public function close(): void - { - ++$this->closeCalls; - - $this->handleClose('Transport closed'); - } - - /** - * @param McpFiber $fiber - */ - private function resumeFiber(\Fiber $fiber): void - { - if (!$fiber->isSuspended() || null === $this->state) { - return; - } - - foreach ($this->state->getPendingRequests() as $pending) { - $response = $this->state->consumeResponse($pending['request_id']); - - if (null !== $response) { - $fiber->resume($response); - - return; - } - - if (time() - $pending['timestamp'] >= $pending['timeout']) { - $fiber->resume(Error::forInternalError('Request timed out', $pending['request_id'])); - - return; - } - } - } -} diff --git a/tests/Unit/Fixtures/Enum/BackedIntEnum.php b/tests/Unit/Fixtures/Enum/BackedIntEnum.php deleted file mode 100644 index 75079c77..00000000 --- a/tests/Unit/Fixtures/Enum/BackedIntEnum.php +++ /dev/null @@ -1,18 +0,0 @@ - - */ -final class ThrowingRequest extends Request -{ - public static function getMethod(): string - { - return 'test/throwing'; - } - - protected static function fromParams(?array $params): static - { - throw new \TypeError('Internal detail that must not leak to the client.'); - } - - protected function getParams(): ?array - { - return null; - } -} diff --git a/tests/Unit/JsonRpc/MalformedInputTest.php b/tests/Unit/JsonRpc/MalformedInputTest.php deleted file mode 100644 index cb8d1ad3..00000000 --- a/tests/Unit/JsonRpc/MalformedInputTest.php +++ /dev/null @@ -1,83 +0,0 @@ - - */ -final class MalformedInputTest extends TestCase -{ - /** - * @return iterable - */ - public static function provideMalformedPayloads(): iterable - { - yield 'method is an object' => ['{"jsonrpc":"2.0","id":1,"method":{"evil":true}}']; - yield 'method is an array' => ['{"jsonrpc":"2.0","id":1,"method":[1,2,3]}']; - - yield 'initialize with array protocolVersion' => ['{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":[1],"capabilities":{},"clientInfo":{"name":"x","version":"1"}}}']; - yield 'initialize with string capabilities' => ['{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":"x","clientInfo":{"name":"x","version":"1"}}}']; - yield 'initialize with string clientInfo' => ['{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":"x"}}']; - yield 'initialize with non-array icons entry' => ['{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"x","version":"1","icons":["x"]}}}']; - - yield 'completion ref/prompt with array name' => ['{"jsonrpc":"2.0","id":1,"method":"completion/complete","params":{"ref":{"type":"ref/prompt","name":[1]},"argument":{"name":"x","value":"y"}}}']; - yield 'completion ref/prompt without name' => ['{"jsonrpc":"2.0","id":1,"method":"completion/complete","params":{"ref":{"type":"ref/prompt"},"argument":{"name":"x","value":"y"}}}']; - yield 'completion ref/resource with object uri' => ['{"jsonrpc":"2.0","id":1,"method":"completion/complete","params":{"ref":{"type":"ref/resource","uri":{}},"argument":{"name":"x","value":"y"}}}']; - - yield 'setLevel with unknown enum value' => ['{"jsonrpc":"2.0","id":1,"method":"logging/setLevel","params":{"level":"not-a-real-level"}}']; - yield 'logging notification with unknown enum value' => ['{"jsonrpc":"2.0","method":"notifications/message","params":{"level":"nope","data":"x"}}']; - - yield 'tools/list with array cursor' => ['{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{"cursor":[1]}}']; - yield 'cancelled notification with array reason' => ['{"jsonrpc":"2.0","method":"notifications/cancelled","params":{"requestId":1,"reason":[1]}}']; - yield 'progress notification with array total' => ['{"jsonrpc":"2.0","method":"notifications/progress","params":{"progressToken":"t","progress":1,"total":[1]}}']; - - yield 'sampling with string preferences' => ['{"jsonrpc":"2.0","id":1,"method":"sampling/createMessage","params":{"messages":[],"maxTokens":1,"preferences":"x"}}']; - yield 'sampling with array systemPrompt' => ['{"jsonrpc":"2.0","id":1,"method":"sampling/createMessage","params":{"messages":[],"maxTokens":1,"systemPrompt":[1]}}']; - yield 'sampling with unknown role' => ['{"jsonrpc":"2.0","id":1,"method":"sampling/createMessage","params":{"messages":[{"role":"nope","content":{"type":"text","text":"x"}}],"maxTokens":1}}']; - yield 'sampling with array content type' => ['{"jsonrpc":"2.0","id":1,"method":"sampling/createMessage","params":{"messages":[{"role":"user","content":{"type":[1],"text":"x"}}],"maxTokens":1}}']; - yield 'sampling with non-string stopSequence' => ['{"jsonrpc":"2.0","id":1,"method":"sampling/createMessage","params":{"messages":[],"maxTokens":1,"stopSequences":[[]]}}']; - - yield 'elicitation with string required' => ['{"jsonrpc":"2.0","id":1,"method":"elicitation/create","params":{"message":"m","requestedSchema":{"type":"object","properties":{"a":{"type":"string","title":"T"}},"required":"a"}}}']; - yield 'elicitation with array in required' => ['{"jsonrpc":"2.0","id":1,"method":"elicitation/create","params":{"message":"m","requestedSchema":{"type":"object","properties":{"a":{"type":"string","title":"T"}},"required":[[]]}}}']; - } - - #[DataProvider('provideMalformedPayloads')] - #[TestDox('Malformed payload is reported as invalid input: $_dataName')] - public function testMalformedPayloadIsReportedAsInvalidInput(string $payload): void - { - $results = MessageFactory::make()->create($payload); - - $this->assertCount(1, $results); - $this->assertInstanceOf(InvalidInputMessageException::class, $results[0]); - } - - #[TestDox('A malformed message in a batch does not discard the valid ones')] - public function testMalformedMessageInBatchDoesNotDiscardValidMessages(): void - { - $payload = '[{"jsonrpc":"2.0","id":1,"method":"tools/list"},{"jsonrpc":"2.0","id":2,"method":{}}]'; - - $results = MessageFactory::make()->create($payload); - - $this->assertCount(2, $results); - $this->assertInstanceOf(\Mcp\Schema\Request\ListToolsRequest::class, $results[0]); - $this->assertInstanceOf(InvalidInputMessageException::class, $results[1]); - } -} diff --git a/tests/Unit/JsonRpc/MessageFactoryTest.php b/tests/Unit/JsonRpc/MessageFactoryTest.php deleted file mode 100644 index 441a500a..00000000 --- a/tests/Unit/JsonRpc/MessageFactoryTest.php +++ /dev/null @@ -1,538 +0,0 @@ -factory = new MessageFactory([ - CancelledNotification::class, - InitializedNotification::class, - GetPromptRequest::class, - PingRequest::class, - ]); - } - - public function testCreateRequestWithIntegerId(): void - { - $json = '{"jsonrpc": "2.0", "method": "prompts/get", "params": {"name": "create_story"}, "id": 123}'; - - $results = $this->factory->create($json); - - $this->assertCount(1, $results); - /** @var GetPromptRequest $result */ - $result = $results[0]; - $this->assertInstanceOf(GetPromptRequest::class, $result); - $this->assertSame('prompts/get', $result::getMethod()); - $this->assertSame('create_story', $result->name); - $this->assertSame(123, $result->getId()); - } - - public function testCreateRequestWithStringId(): void - { - $json = '{"jsonrpc": "2.0", "method": "ping", "id": "abc-123"}'; - - $results = $this->factory->create($json); - - $this->assertCount(1, $results); - /** @var PingRequest $result */ - $result = $results[0]; - $this->assertInstanceOf(PingRequest::class, $result); - $this->assertSame('ping', $result::getMethod()); - $this->assertSame('abc-123', $result->getId()); - } - - public function testCreateNotification(): void - { - $json = '{"jsonrpc": "2.0", "method": "notifications/cancelled", "params": {"requestId": 12345}}'; - - $results = $this->factory->create($json); - - $this->assertCount(1, $results); - /** @var CancelledNotification $result */ - $result = $results[0]; - $this->assertInstanceOf(CancelledNotification::class, $result); - $this->assertSame('notifications/cancelled', $result::getMethod()); - $this->assertSame(12345, $result->requestId); - } - - public function testCreateNotificationWithoutParams(): void - { - $json = '{"jsonrpc": "2.0", "method": "notifications/initialized"}'; - - $results = $this->factory->create($json); - - $this->assertCount(1, $results); - /** @var InitializedNotification $result */ - $result = $results[0]; - $this->assertInstanceOf(InitializedNotification::class, $result); - $this->assertSame('notifications/initialized', $result::getMethod()); - } - - public function testCreateResponseWithIntegerId(): void - { - $json = '{"jsonrpc": "2.0", "id": 456, "result": {"content": [{"type": "text", "text": "Hello"}]}}'; - - $results = $this->factory->create($json); - - $this->assertCount(1, $results); - /** @var Response> $result */ - $result = $results[0]; - $this->assertInstanceOf(Response::class, $result); - $this->assertSame(456, $result->getId()); - $this->assertIsArray($result->result); - $this->assertArrayHasKey('content', $result->result); - } - - public function testCreateResponseWithStringId(): void - { - $json = '{"jsonrpc": "2.0", "id": "response-1", "result": {"status": "ok"}}'; - - $results = $this->factory->create($json); - - $this->assertCount(1, $results); - /** @var Response> $result */ - $result = $results[0]; - $this->assertInstanceOf(Response::class, $result); - $this->assertSame('response-1', $result->getId()); - $this->assertEquals(['status' => 'ok'], $result->result); - } - - public function testCreateErrorWithIntegerId(): void - { - $json = '{"jsonrpc": "2.0", "id": 789, "error": {"code": -32601, "message": "Method not found"}}'; - - $results = $this->factory->create($json); - - $this->assertCount(1, $results); - /** @var Error $result */ - $result = $results[0]; - $this->assertInstanceOf(Error::class, $result); - $this->assertSame(789, $result->getId()); - $this->assertSame(-32601, $result->code); - $this->assertSame('Method not found', $result->message); - $this->assertNull($result->data); - } - - public function testCreateErrorWithStringId(): void - { - $json = '{"jsonrpc": "2.0", "id": "err-1", "error": {"code": -32600, "message": "Invalid request"}}'; - - $results = $this->factory->create($json); - - $this->assertCount(1, $results); - /** @var Error $result */ - $result = $results[0]; - $this->assertInstanceOf(Error::class, $result); - $this->assertSame('err-1', $result->getId()); - $this->assertSame(-32600, $result->code); - $this->assertSame('Invalid request', $result->message); - } - - public function testCreateErrorWithData(): void - { - $json = '{"jsonrpc": "2.0", "id": 1, "error": {"code": -32000, "message": "Server error", "data": {"details": "Something went wrong"}}}'; - - $results = $this->factory->create($json); - - $this->assertCount(1, $results); - /** @var Error $result */ - $result = $results[0]; - $this->assertInstanceOf(Error::class, $result); - $this->assertEquals(['details' => 'Something went wrong'], $result->data); - } - - public function testBatchRequests(): void - { - $json = '[ - {"jsonrpc": "2.0", "method": "ping", "id": 1}, - {"jsonrpc": "2.0", "method": "prompts/get", "params": {"name": "test"}, "id": 2}, - {"jsonrpc": "2.0", "method": "notifications/initialized"} - ]'; - - $results = $this->factory->create($json); - - $this->assertCount(3, $results); - $this->assertInstanceOf(PingRequest::class, $results[0]); - $this->assertInstanceOf(GetPromptRequest::class, $results[1]); - $this->assertInstanceOf(InitializedNotification::class, $results[2]); - } - - public function testBatchWithMixedMessages(): void - { - $json = '[ - {"jsonrpc": "2.0", "method": "ping", "id": 1}, - {"jsonrpc": "2.0", "id": 2, "result": {"status": "ok"}}, - {"jsonrpc": "2.0", "id": 3, "error": {"code": -32600, "message": "Invalid"}}, - {"jsonrpc": "2.0", "method": "notifications/initialized"} - ]'; - - $results = $this->factory->create($json); - - $this->assertCount(4, $results); - $this->assertInstanceOf(PingRequest::class, $results[0]); - $this->assertInstanceOf(Response::class, $results[1]); - $this->assertInstanceOf(Error::class, $results[2]); - $this->assertInstanceOf(InitializedNotification::class, $results[3]); - } - - public function testInvalidJson(): void - { - $this->expectException(\JsonException::class); - - $this->factory->create('invalid json'); - } - - public function testMissingJsonRpcVersion(): void - { - $json = '{"method": "ping", "id": 1}'; - - $results = $this->factory->create($json); - - $this->assertCount(1, $results); - $this->assertInstanceOf(InvalidInputMessageException::class, $results[0]); - $this->assertStringContainsString('jsonrpc', $results[0]->getMessage()); - } - - public function testInvalidJsonRpcVersion(): void - { - $json = '{"jsonrpc": "1.0", "method": "ping", "id": 1}'; - - $results = $this->factory->create($json); - - $this->assertCount(1, $results); - $this->assertInstanceOf(InvalidInputMessageException::class, $results[0]); - $this->assertStringContainsString('jsonrpc', $results[0]->getMessage()); - } - - public function testMissingAllIdentifyingFields(): void - { - $json = '{"jsonrpc": "2.0", "params": {}}'; - - $results = $this->factory->create($json); - - $this->assertCount(1, $results); - $this->assertInstanceOf(InvalidInputMessageException::class, $results[0]); - $this->assertStringContainsString('missing', $results[0]->getMessage()); - } - - public function testUnknownMethod(): void - { - $json = '{"jsonrpc": "2.0", "method": "unknown/method", "id": 1}'; - - $results = $this->factory->create($json); - - $this->assertCount(1, $results); - $this->assertInstanceOf(InvalidInputMessageException::class, $results[0]); - $this->assertStringContainsString('Unknown method', $results[0]->getMessage()); - } - - public function testUnknownNotificationMethod(): void - { - $json = '{"jsonrpc": "2.0", "method": "notifications/unknown"}'; - - $results = $this->factory->create($json); - - $this->assertCount(1, $results); - $this->assertInstanceOf(InvalidInputMessageException::class, $results[0]); - $this->assertStringContainsString('Unknown method', $results[0]->getMessage()); - } - - public function testNotificationMethodUsedAsRequest(): void - { - // When a notification method is used with an id, it should still create the notification - // The fromArray validation will handle any issues - $json = '{"jsonrpc": "2.0", "method": "notifications/initialized", "id": 1}'; - - $results = $this->factory->create($json); - - $this->assertCount(1, $results); - // The notification class will reject the id in fromArray validation - $this->assertInstanceOf(InvalidInputMessageException::class, $results[0]); - } - - public function testErrorMissingId(): void - { - $json = '{"jsonrpc": "2.0", "error": {"code": -32600, "message": "Invalid"}}'; - - $results = $this->factory->create($json); - - $this->assertCount(1, $results); - $this->assertInstanceOf(InvalidInputMessageException::class, $results[0]); - $this->assertStringContainsString('id', $results[0]->getMessage()); - } - - public function testErrorMissingCode(): void - { - $json = '{"jsonrpc": "2.0", "id": 1, "error": {"message": "Invalid"}}'; - - $results = $this->factory->create($json); - - $this->assertCount(1, $results); - $this->assertInstanceOf(InvalidInputMessageException::class, $results[0]); - $this->assertStringContainsString('code', $results[0]->getMessage()); - } - - public function testErrorMissingMessage(): void - { - $json = '{"jsonrpc": "2.0", "id": 1, "error": {"code": -32600}}'; - - $results = $this->factory->create($json); - - $this->assertCount(1, $results); - $this->assertInstanceOf(InvalidInputMessageException::class, $results[0]); - $this->assertStringContainsString('message', $results[0]->getMessage()); - } - - public function testBatchWithErrors(): void - { - $json = '[ - {"jsonrpc": "2.0", "method": "ping", "id": 1}, - {"jsonrpc": "2.0", "params": {}, "id": 2}, - {"jsonrpc": "2.0", "method": "unknown/method", "id": 3}, - {"jsonrpc": "2.0", "method": "notifications/initialized"} - ]'; - - $results = $this->factory->create($json); - - $this->assertCount(4, $results); - $this->assertInstanceOf(PingRequest::class, $results[0]); - $this->assertInstanceOf(InvalidInputMessageException::class, $results[1]); - $this->assertInstanceOf(InvalidInputMessageException::class, $results[2]); - $this->assertInstanceOf(InitializedNotification::class, $results[3]); - } - - public function testMakeFactoryWithDefaultMessages(): void - { - $factory = MessageFactory::make(); - $json = '{"jsonrpc": "2.0", "method": "ping", "id": 1}'; - - $results = $factory->create($json); - - $this->assertCount(1, $results); - $this->assertInstanceOf(PingRequest::class, $results[0]); - } - - public function testMakeFactoryParsesElicitationCreate(): void - { - $factory = MessageFactory::make(); - $json = '{"jsonrpc": "2.0", "method": "elicitation/create", "id": 1, "params": {"message": "Your name?", "requestedSchema": {"type": "object", "properties": {"name": {"type": "string", "title": "Name"}}, "required": ["name"]}}}'; - - $results = $factory->create($json); - - $this->assertCount(1, $results); - /** @var ElicitRequest $result */ - $result = $results[0]; - $this->assertInstanceOf(ElicitRequest::class, $result); - $this->assertSame('elicitation/create', $result::getMethod()); - $this->assertSame('Your name?', $result->message); - $this->assertSame(1, $result->getId()); - } - - public function testResponseWithInvalidIdType(): void - { - $json = '{"jsonrpc": "2.0", "id": true, "result": {"status": "ok"}}'; - - $results = $this->factory->create($json); - - $this->assertCount(1, $results); - $this->assertInstanceOf(InvalidInputMessageException::class, $results[0]); - $this->assertStringContainsString('id', $results[0]->getMessage()); - } - - public function testErrorWithInvalidIdType(): void - { - $json = '{"jsonrpc": "2.0", "id": null, "error": {"code": -32600, "message": "Invalid"}}'; - - $results = $this->factory->create($json); - - $this->assertCount(1, $results); - $this->assertInstanceOf(InvalidInputMessageException::class, $results[0]); - $this->assertStringContainsString('id', $results[0]->getMessage()); - } - - public function testResponseWithNonArrayResult(): void - { - $json = '{"jsonrpc": "2.0", "id": 1, "result": "not an array"}'; - - $results = $this->factory->create($json); - - $this->assertCount(1, $results); - $this->assertInstanceOf(InvalidInputMessageException::class, $results[0]); - $this->assertStringContainsString('result', $results[0]->getMessage()); - } - - public function testErrorWithNonArrayErrorField(): void - { - $json = '{"jsonrpc": "2.0", "id": 1, "error": "not an object"}'; - - $results = $this->factory->create($json); - - $this->assertCount(1, $results); - $this->assertInstanceOf(InvalidInputMessageException::class, $results[0]); - $this->assertStringContainsString('error', $results[0]->getMessage()); - } - - public function testErrorWithInvalidCodeType(): void - { - $json = '{"jsonrpc": "2.0", "id": 1, "error": {"code": "not-a-number", "message": "Invalid"}}'; - - $results = $this->factory->create($json); - - $this->assertCount(1, $results); - $this->assertInstanceOf(InvalidInputMessageException::class, $results[0]); - $this->assertStringContainsString('code', $results[0]->getMessage()); - } - - public function testErrorWithInvalidMessageType(): void - { - $json = '{"jsonrpc": "2.0", "id": 1, "error": {"code": -32600, "message": 123}}'; - - $results = $this->factory->create($json); - - $this->assertCount(1, $results); - $this->assertInstanceOf(InvalidInputMessageException::class, $results[0]); - $this->assertStringContainsString('message', $results[0]->getMessage()); - } - - public function testScalarJsonIsRejected(): void - { - $results = $this->factory->create('5'); - - $this->assertCount(1, $results); - $this->assertInstanceOf(InvalidInputMessageException::class, $results[0]); - } - - public function testStringJsonIsRejected(): void - { - $results = $this->factory->create('"hello"'); - - $this->assertCount(1, $results); - $this->assertInstanceOf(InvalidInputMessageException::class, $results[0]); - } - - public function testEmptyBatchIsRejected(): void - { - $results = $this->factory->create('[]'); - - $this->assertCount(1, $results); - $this->assertInstanceOf(InvalidInputMessageException::class, $results[0]); - } - - public function testBatchElementMustBeObject(): void - { - $results = $this->factory->create('[1, 2]'); - - $this->assertCount(2, $results); - $this->assertInstanceOf(InvalidInputMessageException::class, $results[0]); - $this->assertInstanceOf(InvalidInputMessageException::class, $results[1]); - } - - /** - * @return iterable - */ - public static function provideNonStringMethods(): iterable - { - yield 'object' => ['{"evil": true}']; - yield 'array' => ['[1, 2, 3]']; - yield 'int' => ['5']; - yield 'bool' => ['true']; - } - - #[DataProvider('provideNonStringMethods')] - public function testNonStringMethodIsRejected(string $method): void - { - $results = $this->factory->create(\sprintf('{"jsonrpc": "2.0", "id": 1, "method": %s}', $method)); - - $this->assertCount(1, $results); - $this->assertInstanceOf(InvalidInputMessageException::class, $results[0]); - $this->assertStringContainsString('"method" must be a string', $results[0]->getMessage()); - } - - public function testBatchWithNonStringMethodStillYieldsTheValidMessages(): void - { - $json = '[ - {"jsonrpc": "2.0", "method": "ping", "id": 1}, - {"jsonrpc": "2.0", "method": {}, "id": 2} - ]'; - - $results = $this->factory->create($json); - - $this->assertCount(2, $results); - $this->assertInstanceOf(PingRequest::class, $results[0]); - $this->assertInstanceOf(InvalidInputMessageException::class, $results[1]); - } - - public function testLeadingWhitespaceObjectIsParsedAsSingleMessage(): void - { - $json = " \n {\"jsonrpc\": \"2.0\", \"method\": \"ping\", \"id\": 1}"; - - $results = $this->factory->create($json); - - $this->assertCount(1, $results); - $this->assertInstanceOf(PingRequest::class, $results[0]); - } - - public function testBatchSizeExceedingMaxIsRejected(): void - { - $factory = new MessageFactory([PingRequest::class], maxBatchSize: 2); - $json = '[ - {"jsonrpc": "2.0", "method": "ping", "id": 1}, - {"jsonrpc": "2.0", "method": "ping", "id": 2}, - {"jsonrpc": "2.0", "method": "ping", "id": 3} - ]'; - - $results = $factory->create($json); - - $this->assertCount(1, $results); - $this->assertInstanceOf(InvalidInputMessageException::class, $results[0]); - $this->assertStringContainsString('batch', $results[0]->getMessage()); - } - - public function testBatchSizeWithinMaxIsAccepted(): void - { - $factory = new MessageFactory([PingRequest::class], maxBatchSize: 2); - $json = '[ - {"jsonrpc": "2.0", "method": "ping", "id": 1}, - {"jsonrpc": "2.0", "method": "ping", "id": 2} - ]'; - - $results = $factory->create($json); - - $this->assertCount(2, $results); - $this->assertInstanceOf(PingRequest::class, $results[0]); - $this->assertInstanceOf(PingRequest::class, $results[1]); - } - - public function testNonPositiveMaxBatchSizeThrows(): void - { - $this->expectException(InvalidArgumentException::class); - - new MessageFactory([PingRequest::class], maxBatchSize: 0); - } -} diff --git a/tests/Unit/Schema/ClientCapabilitiesTest.php b/tests/Unit/Schema/ClientCapabilitiesTest.php deleted file mode 100644 index bee2e588..00000000 --- a/tests/Unit/Schema/ClientCapabilitiesTest.php +++ /dev/null @@ -1,114 +0,0 @@ -assertArrayHasKey('roots', $data); - $this->assertSame([], $data['roots']); - } - - public function testSerializesRootsWithListChanged(): void - { - $capabilities = new ClientCapabilities(roots: true, rootsListChanged: true); - - $data = json_decode(json_encode($capabilities), true); - - $this->assertSame(['listChanged' => true], $data['roots']); - } - - public function testSerializesEmptyCapabilitiesAsObject(): void - { - $capabilities = new ClientCapabilities(); - - $this->assertSame('{}', json_encode($capabilities)); - } - - public function testFromArrayReadsRootsListChanged(): void - { - $capabilities = ClientCapabilities::fromArray(['roots' => ['listChanged' => true]]); - - $this->assertTrue($capabilities->roots); - $this->assertTrue($capabilities->rootsListChanged); - } - - public function testFromArrayRootsWithoutListChanged(): void - { - $capabilities = ClientCapabilities::fromArray(['roots' => []]); - - $this->assertTrue($capabilities->roots); - $this->assertNull($capabilities->rootsListChanged); - } - - public function testRoundTripPreservesRootsListChanged(): void - { - $capabilities = new ClientCapabilities(roots: true, rootsListChanged: true); - - $data = json_decode(json_encode($capabilities), true); - $restored = ClientCapabilities::fromArray($data); - - $this->assertTrue($restored->roots); - $this->assertTrue($restored->rootsListChanged); - } - - public function testRoundTripPreservesSamplingSubCapabilities(): void - { - $capabilities = new ClientCapabilities(sampling: true, samplingContext: true, samplingTools: true); - - $serialized = $capabilities->jsonSerialize(); - $this->assertObjectHasProperty('context', $serialized['sampling']); - $this->assertObjectHasProperty('tools', $serialized['sampling']); - - $restored = ClientCapabilities::fromArray(json_decode(json_encode($capabilities), true)); - - $this->assertTrue($restored->sampling); - $this->assertTrue($restored->samplingContext); - $this->assertTrue($restored->samplingTools); - } - - public function testPlainSamplingLeavesSubCapabilitiesOff(): void - { - $capabilities = new ClientCapabilities(sampling: true); - - $serialized = $capabilities->jsonSerialize(); - $this->assertObjectNotHasProperty('context', $serialized['sampling']); - $this->assertObjectNotHasProperty('tools', $serialized['sampling']); - - $restored = ClientCapabilities::fromArray(json_decode(json_encode($capabilities), true)); - - $this->assertTrue($restored->sampling); - $this->assertFalse($restored->samplingContext); - $this->assertFalse($restored->samplingTools); - } - - public function testSamplingSubCapabilitiesAreHydratedFromObject(): void - { - $sampling = new \stdClass(); - $sampling->context = new \stdClass(); - $sampling->tools = new \stdClass(); - - $capabilities = ClientCapabilities::fromArray(['sampling' => $sampling]); - - $this->assertTrue($capabilities->sampling); - $this->assertTrue($capabilities->samplingContext); - $this->assertTrue($capabilities->samplingTools); - } -} diff --git a/tests/Unit/Schema/Content/ImageContentTest.php b/tests/Unit/Schema/Content/ImageContentTest.php deleted file mode 100644 index b1672021..00000000 --- a/tests/Unit/Schema/Content/ImageContentTest.php +++ /dev/null @@ -1,87 +0,0 @@ -assertNull($content->annotations); - } - - public function testConstructorAcceptsAnnotations(): void - { - $annotations = new Annotations([Role::User], 0.5); - $content = new ImageContent(base64_encode('binary'), 'image/png', $annotations); - - $this->assertSame($annotations, $content->annotations); - } - - public function testFromArrayDeserializesAnnotations(): void - { - $content = ImageContent::fromArray([ - 'type' => 'image', - 'data' => base64_encode('binary'), - 'mimeType' => 'image/png', - 'annotations' => ['audience' => ['user'], 'priority' => 0.5], - ]); - - $this->assertNotNull($content->annotations); - $this->assertSame([Role::User], $content->annotations->audience); - $this->assertSame(0.5, $content->annotations->priority); - } - - public function testJsonSerializeOmitsNullAnnotations(): void - { - $content = new ImageContent(base64_encode('binary'), 'image/png'); - - $this->assertArrayNotHasKey('annotations', $content->jsonSerialize()); - } - - public function testJsonSerializeIncludesAnnotations(): void - { - $annotations = new Annotations([Role::User], 0.5); - $content = new ImageContent(base64_encode('binary'), 'image/png', $annotations); - - $data = $content->jsonSerialize(); - - $this->assertSame($annotations, $data['annotations']); - } - - public function testRoundTripWithAnnotations(): void - { - $original = new ImageContent(base64_encode('binary'), 'image/png', new Annotations([Role::User], 0.5)); - - $decoded = json_decode(json_encode($original), true); - $rehydrated = ImageContent::fromArray($decoded); - - $this->assertSame($original->data, $rehydrated->data); - $this->assertSame($original->mimeType, $rehydrated->mimeType); - $this->assertEquals($original->annotations, $rehydrated->annotations); - } - - public function testFromStringAcceptsAnnotations(): void - { - $annotations = new Annotations([Role::User]); - $content = ImageContent::fromString('binary', 'image/png', $annotations); - - $this->assertSame(base64_encode('binary'), $content->data); - $this->assertSame($annotations, $content->annotations); - } -} diff --git a/tests/Unit/Schema/Content/PromptMessageTest.php b/tests/Unit/Schema/Content/PromptMessageTest.php deleted file mode 100644 index 8808d412..00000000 --- a/tests/Unit/Schema/Content/PromptMessageTest.php +++ /dev/null @@ -1,84 +0,0 @@ - 'user', - 'content' => [ - 'type' => 'resource_link', - 'uri' => 'file:///project/src/main.rs', - 'name' => 'main.rs', - ], - ]); - - $this->assertSame(Role::User, $message->role); - $this->assertInstanceOf(ResourceLink::class, $message->content); - $this->assertSame('file:///project/src/main.rs', $message->content->uri); - $this->assertSame('main.rs', $message->content->name); - } - - public function testJsonSerializeIncludesResourceLinkContent(): void - { - $message = new PromptMessage(Role::Assistant, new ResourceLink('file:///a.png', 'a.png')); - - $this->assertSame([ - 'role' => 'assistant', - 'content' => [ - 'type' => 'resource_link', - 'uri' => 'file:///a.png', - 'name' => 'a.png', - ], - ], json_decode(json_encode($message), true)); - } - - public function testRoundTripWithResourceLink(): void - { - $original = new PromptMessage(Role::User, new ResourceLink('file:///a.png', 'a.png', mimeType: 'image/png')); - - $decoded = json_decode(json_encode($original), true); - $rehydrated = PromptMessage::fromArray($decoded); - - $this->assertSame(Role::User, $rehydrated->role); - $this->assertInstanceOf(ResourceLink::class, $rehydrated->content); - $this->assertSame('file:///a.png', $rehydrated->content->uri); - $this->assertSame('image/png', $rehydrated->content->mimeType); - } - - public function testFromArrayRejectsUnknownContentType(): void - { - $this->expectException(InvalidArgumentException::class); - - /* @phpstan-ignore argument.type */ - PromptMessage::fromArray([ - 'role' => 'user', - 'content' => ['type' => 'not-a-real-type'], - ]); - } - - public function testConstructorAcceptsResourceLinkContent(): void - { - $link = new ResourceLink('file:///a.png', 'a.png'); - $message = new PromptMessage(Role::User, $link); - - $this->assertSame($link, $message->content); - } -} diff --git a/tests/Unit/Schema/Content/ResourceLinkTest.php b/tests/Unit/Schema/Content/ResourceLinkTest.php deleted file mode 100644 index ca064558..00000000 --- a/tests/Unit/Schema/Content/ResourceLinkTest.php +++ /dev/null @@ -1,251 +0,0 @@ -assertSame('resource_link', $link->type); - $this->assertSame(self::VALID_URI, $link->uri); - $this->assertSame('main.rs', $link->name); - $this->assertNull($link->title); - $this->assertNull($link->description); - $this->assertNull($link->mimeType); - $this->assertNull($link->annotations); - $this->assertNull($link->size); - $this->assertNull($link->icons); - $this->assertNull($link->meta); - } - - public function testConstructorWithAllFields(): void - { - $annotations = new Annotations([Role::User], 0.5); - $icons = [new Icon('https://example.com/icon.png')]; - - $link = new ResourceLink( - uri: self::VALID_URI, - name: 'main.rs', - title: 'Main Source File', - description: 'Primary application entry point', - mimeType: 'text/x-rust', - annotations: $annotations, - size: 1024, - icons: $icons, - meta: ['origin' => 'test'], - ); - - $this->assertSame(self::VALID_URI, $link->uri); - $this->assertSame('main.rs', $link->name); - $this->assertSame('Main Source File', $link->title); - $this->assertSame('Primary application entry point', $link->description); - $this->assertSame('text/x-rust', $link->mimeType); - $this->assertSame($annotations, $link->annotations); - $this->assertSame(1024, $link->size); - $this->assertSame($icons, $link->icons); - $this->assertSame(['origin' => 'test'], $link->meta); - } - - public function testJsonSerializeMinimal(): void - { - $link = new ResourceLink(self::VALID_URI, 'main.rs'); - - $this->assertSame([ - 'type' => 'resource_link', - 'uri' => self::VALID_URI, - 'name' => 'main.rs', - ], $link->jsonSerialize()); - } - - public function testJsonSerializeWithAllFields(): void - { - $annotations = new Annotations([Role::User], 0.5); - $icons = [new Icon('https://example.com/icon.png')]; - - $link = new ResourceLink( - uri: self::VALID_URI, - name: 'main.rs', - title: 'Main Source File', - description: 'Primary application entry point', - mimeType: 'text/x-rust', - annotations: $annotations, - size: 1024, - icons: $icons, - meta: ['origin' => 'test'], - ); - - $data = $link->jsonSerialize(); - - $this->assertSame('resource_link', $data['type']); - $this->assertSame(self::VALID_URI, $data['uri']); - $this->assertSame('main.rs', $data['name']); - $this->assertSame('Main Source File', $data['title']); - $this->assertSame('Primary application entry point', $data['description']); - $this->assertSame('text/x-rust', $data['mimeType']); - $this->assertSame($annotations, $data['annotations']); - $this->assertSame(1024, $data['size']); - $this->assertSame($icons, $data['icons']); - $this->assertSame(['origin' => 'test'], $data['_meta']); - } - - public function testOptionalFieldsOmittedWhenNull(): void - { - $link = new ResourceLink(self::VALID_URI, 'main.rs'); - $data = $link->jsonSerialize(); - - $this->assertArrayNotHasKey('title', $data); - $this->assertArrayNotHasKey('description', $data); - $this->assertArrayNotHasKey('mimeType', $data); - $this->assertArrayNotHasKey('annotations', $data); - $this->assertArrayNotHasKey('size', $data); - $this->assertArrayNotHasKey('icons', $data); - $this->assertArrayNotHasKey('_meta', $data); - } - - public function testFromArrayMinimal(): void - { - $link = ResourceLink::fromArray([ - 'type' => 'resource_link', - 'uri' => self::VALID_URI, - 'name' => 'main.rs', - ]); - - $this->assertSame(self::VALID_URI, $link->uri); - $this->assertSame('main.rs', $link->name); - $this->assertNull($link->title); - $this->assertNull($link->annotations); - $this->assertNull($link->icons); - $this->assertNull($link->meta); - } - - public function testFromArrayWithAllFields(): void - { - $link = ResourceLink::fromArray([ - 'type' => 'resource_link', - 'uri' => self::VALID_URI, - 'name' => 'main.rs', - 'title' => 'Main Source File', - 'description' => 'Primary application entry point', - 'mimeType' => 'text/x-rust', - 'annotations' => ['audience' => ['user'], 'priority' => 0.5], - 'size' => 1024, - 'icons' => [['src' => 'https://example.com/icon.png']], - '_meta' => ['origin' => 'test'], - ]); - - $this->assertSame('Main Source File', $link->title); - $this->assertSame('Primary application entry point', $link->description); - $this->assertSame('text/x-rust', $link->mimeType); - $this->assertInstanceOf(Annotations::class, $link->annotations); - $this->assertSame(1024, $link->size); - $this->assertCount(1, $link->icons); - $this->assertInstanceOf(Icon::class, $link->icons[0]); - $this->assertSame(['origin' => 'test'], $link->meta); - } - - public function testRoundTripThroughJsonSerializeAndFromArray(): void - { - $original = new ResourceLink( - uri: self::VALID_URI, - name: 'main.rs', - title: 'Main Source File', - description: 'Primary application entry point', - mimeType: 'text/x-rust', - annotations: new Annotations([Role::User], 0.5), - size: 1024, - icons: [new Icon('https://example.com/icon.png')], - meta: ['origin' => 'test'], - ); - - $decoded = json_decode(json_encode($original), true); - $rehydrated = ResourceLink::fromArray($decoded); - - $this->assertSame($original->uri, $rehydrated->uri); - $this->assertSame($original->name, $rehydrated->name); - $this->assertSame($original->title, $rehydrated->title); - $this->assertSame($original->description, $rehydrated->description); - $this->assertSame($original->mimeType, $rehydrated->mimeType); - $this->assertSame($original->size, $rehydrated->size); - $this->assertSame($original->meta, $rehydrated->meta); - $this->assertEquals($original->annotations, $rehydrated->annotations); - } - - public function testFromArrayRejectsWrongType(): void - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Invalid type for ResourceLink.'); - - /* @phpstan-ignore argument.type */ - ResourceLink::fromArray([ - 'type' => 'resource', - 'uri' => self::VALID_URI, - 'name' => 'main.rs', - ]); - } - - #[DataProvider('provideInvalidData')] - public function testFromArrayRejectsInvalidData(array $input, string $expectedExceptionMessage): void - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage($expectedExceptionMessage); - - ResourceLink::fromArray($input); - } - - public static function provideInvalidData(): iterable - { - yield 'missing uri' => [ - ['type' => 'resource_link', 'name' => 'main.rs'], - 'Invalid or missing "uri" in ResourceLink data.', - ]; - yield 'missing name' => [ - ['type' => 'resource_link', 'uri' => self::VALID_URI], - 'Invalid or missing "name" in ResourceLink data.', - ]; - yield 'invalid _meta' => [ - ['type' => 'resource_link', 'uri' => self::VALID_URI, 'name' => 'main.rs', '_meta' => 'not-an-array'], - 'Invalid "_meta" in ResourceLink data.', - ]; - yield 'invalid description' => [ - ['type' => 'resource_link', 'uri' => self::VALID_URI, 'name' => 'main.rs', 'description' => ['not-a-string']], - 'Invalid "description" in ResourceLink data.', - ]; - yield 'invalid mimeType' => [ - ['type' => 'resource_link', 'uri' => self::VALID_URI, 'name' => 'main.rs', 'mimeType' => ['not-a-string']], - 'Invalid "mimeType" in ResourceLink data.', - ]; - yield 'invalid size' => [ - ['type' => 'resource_link', 'uri' => self::VALID_URI, 'name' => 'main.rs', 'size' => 'not-an-int'], - 'Invalid "size" in ResourceLink data; expected an integer.', - ]; - yield 'invalid annotations' => [ - ['type' => 'resource_link', 'uri' => self::VALID_URI, 'name' => 'main.rs', 'annotations' => 'not-an-array'], - 'Invalid "annotations" in ResourceLink data; expected an array.', - ]; - yield 'invalid icons entry' => [ - ['type' => 'resource_link', 'uri' => self::VALID_URI, 'name' => 'main.rs', 'icons' => ['not-an-array']], - 'Each entry in "icons" of ResourceLink data must be an array.', - ]; - } -} diff --git a/tests/Unit/Schema/Content/SamplingMessageTest.php b/tests/Unit/Schema/Content/SamplingMessageTest.php deleted file mode 100644 index c464575b..00000000 --- a/tests/Unit/Schema/Content/SamplingMessageTest.php +++ /dev/null @@ -1,125 +0,0 @@ - 'assistant', - '_meta' => ['provider' => 'test'], - 'content' => [ - ['type' => 'text', 'text' => 'I will check.'], - ['type' => 'tool_use', 'id' => 'call-1', 'name' => 'weather', 'input' => ['city' => 'Paris']], - ], - ]); - $user = SamplingMessage::fromArray([ - 'role' => 'user', - 'content' => [[ - 'type' => 'tool_result', - 'toolUseId' => 'call-1', - 'content' => [['type' => 'text', 'text' => '21 C']], - 'structuredContent' => ['temperature' => 21], - ]], - ]); - - $this->assertInstanceOf(ToolUseContent::class, $assistant->content[1]); - $this->assertSame(['provider' => 'test'], $assistant->meta); - $this->assertSame(['provider' => 'test'], $assistant->jsonSerialize()['_meta']); - $this->assertInstanceOf(ToolResultContent::class, $user->content[0]); - $this->assertSame(['temperature' => 21], $user->content[0]->structuredContent); - - $this->assertEquals($assistant, SamplingMessage::fromArray(json_decode(json_encode($assistant), true))); - $this->assertEquals($user, SamplingMessage::fromArray(json_decode(json_encode($user), true))); - } - - public function testSingleContentBlockKeepsItsShape(): void - { - $message = SamplingMessage::fromArray(['role' => 'user', 'content' => ['type' => 'text', 'text' => 'hi']]); - - $this->assertInstanceOf(TextContent::class, $message->content); - $this->assertSame('{"role":"user","content":{"type":"text","text":"hi"}}', json_encode($message)); - $this->assertCount(1, $message->getContentBlocks()); - } - - public function testSingleElementListKeepsItsShape(): void - { - $message = SamplingMessage::fromArray(['role' => 'user', 'content' => [['type' => 'text', 'text' => 'hi']]]); - - $this->assertIsArray($message->content); - $this->assertSame('{"role":"user","content":[{"type":"text","text":"hi"}]}', json_encode($message)); - $this->assertCount(1, $message->getContentBlocks()); - } - - /** - * The tool-flow rules span the whole message list, so a single message is never - * rejected for carrying the "wrong" block — see CreateSamplingMessageRequestTest. - */ - public function testToolBlocksAreAcceptedRegardlessOfRole(): void - { - $message = new SamplingMessage(Role::User, new ToolUseContent('call-1', 'weather', [])); - - $this->assertInstanceOf(ToolUseContent::class, $message->content); - } - - public function testFilteredContentStillSerializesAsAnArray(): void - { - $blocks = [new TextContent('thinking'), new ToolUseContent('call-1', 'weather', [])]; - - // array_filter() preserves keys, so this list starts at index 1. - $toolUses = array_filter($blocks, static fn ($block): bool => $block instanceof ToolUseContent); - $message = new SamplingMessage(Role::Assistant, $toolUses); - - $this->assertSame( - '{"role":"assistant","content":[{"type":"tool_use","id":"call-1","name":"weather","input":{}}]}', - json_encode($message), - ); - } - - public function testEmptyContentListIsRejected(): void - { - $this->expectException(InvalidArgumentException::class); - - new SamplingMessage(Role::User, []); - } - - public function testEmptyContentListIsRejectedWhenHydrating(): void - { - $this->expectException(InvalidArgumentException::class); - - SamplingMessage::fromArray(['role' => 'user', 'content' => []]); - } - - public function testUnknownContentTypeIsRejected(): void - { - $this->expectException(InvalidArgumentException::class); - - SamplingMessage::fromArray(['role' => 'user', 'content' => ['type' => 'nope']]); - } - - public function testUnknownRoleIsRejected(): void - { - $this->expectException(InvalidArgumentException::class); - - /* @phpstan-ignore argument.type */ - SamplingMessage::fromArray(['role' => 'system', 'content' => ['type' => 'text', 'text' => 'hi']]); - } -} diff --git a/tests/Unit/Schema/Content/ToolResultContentTest.php b/tests/Unit/Schema/Content/ToolResultContentTest.php deleted file mode 100644 index 3c45f664..00000000 --- a/tests/Unit/Schema/Content/ToolResultContentTest.php +++ /dev/null @@ -1,119 +0,0 @@ - 'tool_result', - 'toolUseId' => 'call-1', - 'content' => [['type' => 'text', 'text' => '21 C']], - 'structuredContent' => ['temperature' => 21], - 'isError' => true, - '_meta' => ['provider' => 'test'], - ]); - - $this->assertSame('tool_result', $content->type); - $this->assertSame('call-1', $content->toolUseId); - $this->assertSame(['temperature' => 21], $content->structuredContent); - $this->assertTrue($content->isError); - $this->assertSame(['provider' => 'test'], $content->meta); - - $textContent = $content->content[0]; - $this->assertInstanceOf(TextContent::class, $textContent); - $this->assertSame('21 C', $textContent->text); - - $restored = ToolResultContent::fromArray(json_decode(json_encode($content), true)); - $this->assertEquals($content, $restored); - } - - public function testIsErrorIsOmittedWhenFalse(): void - { - $content = new ToolResultContent('call-1', [new TextContent('ok')]); - - $serialized = $content->jsonSerialize(); - - $this->assertArrayNotHasKey('isError', $serialized); - $this->assertArrayNotHasKey('structuredContent', $serialized); - $this->assertArrayNotHasKey('_meta', $serialized); - $this->assertFalse(ToolResultContent::fromArray(json_decode(json_encode($content), true))->isError); - } - - public function testAcceptsEveryCallToolResultContentBlock(): void - { - $content = ToolResultContent::fromArray([ - 'toolUseId' => 'call-1', - 'content' => [ - ['type' => 'text', 'text' => 'a'], - ['type' => 'image', 'data' => base64_encode('img'), 'mimeType' => 'image/png'], - ['type' => 'audio', 'data' => base64_encode('snd'), 'mimeType' => 'audio/wav'], - ['type' => 'resource_link', 'uri' => 'file:///report.txt', 'name' => 'report'], - ['type' => 'resource', 'resource' => ['uri' => 'file:///a.txt', 'mimeType' => 'text/plain', 'text' => 'a']], - ], - ]); - - $this->assertInstanceOf(ResourceLink::class, $content->content[3]); - $this->assertInstanceOf(EmbeddedResource::class, $content->content[4]); - } - - public function testFilteredContentStillSerializesAsAnArray(): void - { - $blocks = [new TextContent('drop me'), new TextContent('keep me')]; - - // array_filter() preserves keys, so this list starts at index 1. - $kept = array_filter($blocks, static fn (TextContent $block): bool => 'keep me' === $block->text); - $content = new ToolResultContent('call-1', $kept); - - $this->assertSame( - '{"type":"tool_result","toolUseId":"call-1","content":[{"type":"text","text":"keep me"}]}', - json_encode($content), - ); - } - - public function testRejectsNonStandardContentBlocks(): void - { - $this->expectException(InvalidArgumentException::class); - - /* @phpstan-ignore argument.type */ - new ToolResultContent('call-1', [ - new SamplingMessage(Role::User, new TextContent('not a tool result content block')), - ]); - } - - public function testRejectsUnsupportedContentType(): void - { - $this->expectException(InvalidArgumentException::class); - - ToolResultContent::fromArray([ - 'toolUseId' => 'call-1', - 'content' => [['type' => 'tool_use', 'id' => 'x', 'name' => 'y', 'input' => []]], - ]); - } - - public function testRejectsMissingToolUseId(): void - { - $this->expectException(InvalidArgumentException::class); - - ToolResultContent::fromArray(['content' => []]); - } -} diff --git a/tests/Unit/Schema/Content/ToolUseContentTest.php b/tests/Unit/Schema/Content/ToolUseContentTest.php deleted file mode 100644 index bf7d07b0..00000000 --- a/tests/Unit/Schema/Content/ToolUseContentTest.php +++ /dev/null @@ -1,80 +0,0 @@ - 'tool_use', - 'id' => 'call-1', - 'name' => 'weather', - 'input' => ['city' => 'Paris'], - '_meta' => ['provider' => 'test'], - ]); - - $this->assertSame('tool_use', $content->type); - $this->assertSame('call-1', $content->id); - $this->assertSame('weather', $content->name); - $this->assertSame(['city' => 'Paris'], $content->input); - $this->assertSame(['provider' => 'test'], $content->meta); - - $this->assertSame( - '{"type":"tool_use","id":"call-1","name":"weather","input":{"city":"Paris"},"_meta":{"provider":"test"}}', - json_encode($content), - ); - } - - public function testEmptyInputSerializesAsObject(): void - { - $content = new ToolUseContent('call-1', 'ping', []); - - $this->assertSame('{"type":"tool_use","id":"call-1","name":"ping","input":{}}', json_encode($content)); - } - - public function testEmptyInputSurvivesRoundTrip(): void - { - $decoded = json_decode(json_encode(new ToolUseContent('call-1', 'ping', [])), true); - - $this->assertSame([], ToolUseContent::fromArray($decoded)->input); - } - - /** - * @return iterable}> - */ - public static function provideInvalidData(): iterable - { - yield 'missing id' => [['name' => 'weather', 'input' => []]]; - yield 'non-string id' => [['id' => 1, 'name' => 'weather', 'input' => []]]; - yield 'missing name' => [['id' => 'call-1', 'input' => []]]; - yield 'non-string name' => [['id' => 'call-1', 'name' => 1, 'input' => []]]; - yield 'missing input' => [['id' => 'call-1', 'name' => 'weather']]; - yield 'non-array input' => [['id' => 'call-1', 'name' => 'weather', 'input' => 'nope']]; - } - - /** - * @param array $data - */ - #[DataProvider('provideInvalidData')] - public function testInvalidDataIsRejected(array $data): void - { - $this->expectException(InvalidArgumentException::class); - - ToolUseContent::fromArray($data); - } -} diff --git a/tests/Unit/Schema/Elicitation/BooleanSchemaDefinitionTest.php b/tests/Unit/Schema/Elicitation/BooleanSchemaDefinitionTest.php deleted file mode 100644 index fefea478..00000000 --- a/tests/Unit/Schema/Elicitation/BooleanSchemaDefinitionTest.php +++ /dev/null @@ -1,108 +0,0 @@ -assertSame('Confirm', $schema->title); - $this->assertNull($schema->description); - $this->assertNull($schema->default); - } - - public function testConstructorWithAllParams(): void - { - $schema = new BooleanSchemaDefinition( - title: 'Confirmation', - description: 'Do you confirm this action?', - default: false, - ); - - $this->assertSame('Confirmation', $schema->title); - $this->assertSame('Do you confirm this action?', $schema->description); - $this->assertFalse($schema->default); - } - - public function testConstructorWithTrueDefault(): void - { - $schema = new BooleanSchemaDefinition( - title: 'Subscribe', - default: true, - ); - - $this->assertTrue($schema->default); - } - - public function testFromArrayWithMinimalParams(): void - { - $schema = BooleanSchemaDefinition::fromArray(['title' => 'Confirm']); - - $this->assertSame('Confirm', $schema->title); - $this->assertNull($schema->description); - $this->assertNull($schema->default); - } - - public function testFromArrayWithAllParams(): void - { - $schema = BooleanSchemaDefinition::fromArray([ - 'title' => 'Confirmation', - 'description' => 'Do you confirm this action?', - 'default' => true, - ]); - - $this->assertSame('Confirmation', $schema->title); - $this->assertSame('Do you confirm this action?', $schema->description); - $this->assertTrue($schema->default); - } - - public function testFromArrayWithMissingTitle(): void - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Missing or invalid "title"'); - - /* @phpstan-ignore argument.type */ - BooleanSchemaDefinition::fromArray([]); - } - - public function testJsonSerializeWithMinimalParams(): void - { - $schema = new BooleanSchemaDefinition('Confirm'); - - $this->assertSame([ - 'type' => 'boolean', - 'title' => 'Confirm', - ], $schema->jsonSerialize()); - } - - public function testJsonSerializeWithAllParams(): void - { - $schema = new BooleanSchemaDefinition( - title: 'Confirmation', - description: 'Do you confirm this action?', - default: false, - ); - - $this->assertSame([ - 'type' => 'boolean', - 'title' => 'Confirmation', - 'description' => 'Do you confirm this action?', - 'default' => false, - ], $schema->jsonSerialize()); - } -} diff --git a/tests/Unit/Schema/Elicitation/ElicitationSchemaTest.php b/tests/Unit/Schema/Elicitation/ElicitationSchemaTest.php deleted file mode 100644 index 3117de90..00000000 --- a/tests/Unit/Schema/Elicitation/ElicitationSchemaTest.php +++ /dev/null @@ -1,317 +0,0 @@ - new StringSchemaDefinition('Name'), - ]; - - $schema = new ElicitationSchema($properties); - - $this->assertCount(1, $schema->properties); - $this->assertSame([], $schema->required); - } - - public function testConstructorWithRequiredFields(): void - { - $properties = [ - 'name' => new StringSchemaDefinition('Name'), - 'email' => new StringSchemaDefinition('Email'), - ]; - - $schema = new ElicitationSchema($properties, ['name']); - - $this->assertCount(2, $schema->properties); - $this->assertSame(['name'], $schema->required); - } - - public function testConstructorWithMultipleTypes(): void - { - $properties = [ - 'name' => new StringSchemaDefinition('Name'), - 'age' => new NumberSchemaDefinition('Age', integerOnly: true), - 'subscribe' => new BooleanSchemaDefinition('Subscribe'), - 'rating' => new EnumSchemaDefinition('Rating', ['1', '2', '3', '4', '5']), - ]; - - $schema = new ElicitationSchema($properties, ['name', 'age']); - - $this->assertCount(4, $schema->properties); - $this->assertInstanceOf(StringSchemaDefinition::class, $schema->properties['name']); - $this->assertInstanceOf(NumberSchemaDefinition::class, $schema->properties['age']); - $this->assertInstanceOf(BooleanSchemaDefinition::class, $schema->properties['subscribe']); - $this->assertInstanceOf(EnumSchemaDefinition::class, $schema->properties['rating']); - } - - public function testConstructorWithEmptyProperties(): void - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('properties array must not be empty'); - - new ElicitationSchema([]); - } - - public function testConstructorWithInvalidRequired(): void - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Required property "unknown" is not defined in properties'); - - new ElicitationSchema( - ['name' => new StringSchemaDefinition('Name')], - ['unknown'], - ); - } - - public function testFromArrayWithMinimalParams(): void - { - $schema = ElicitationSchema::fromArray([ - 'properties' => [ - 'name' => ['type' => 'string', 'title' => 'Name'], - ], - ]); - - $this->assertCount(1, $schema->properties); - $this->assertInstanceOf(StringSchemaDefinition::class, $schema->properties['name']); - } - - public function testFromArrayWithExplicitObjectType(): void - { - $schema = ElicitationSchema::fromArray([ - 'type' => 'object', - 'properties' => [ - 'name' => ['type' => 'string', 'title' => 'Name'], - ], - ]); - - $this->assertCount(1, $schema->properties); - } - - public function testFromArrayWithInvalidType(): void - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('ElicitationSchema type must be "object"'); - - ElicitationSchema::fromArray([ - 'type' => 'array', - 'properties' => [ - 'name' => ['type' => 'string', 'title' => 'Name'], - ], - ]); - } - - public function testFromArrayWithMissingProperties(): void - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Missing or invalid "properties"'); - - /* @phpstan-ignore argument.type */ - ElicitationSchema::fromArray([]); - } - - public function testFromArrayWithRequiredFields(): void - { - $schema = ElicitationSchema::fromArray([ - 'properties' => [ - 'name' => ['type' => 'string', 'title' => 'Name'], - 'email' => ['type' => 'string', 'title' => 'Email', 'format' => 'email'], - ], - 'required' => ['name'], - ]); - - $this->assertSame(['name'], $schema->required); - } - - public function testFromArrayWithMultipleTypes(): void - { - $schema = ElicitationSchema::fromArray([ - 'properties' => [ - 'name' => ['type' => 'string', 'title' => 'Name'], - 'age' => ['type' => 'integer', 'title' => 'Age', 'minimum' => 0], - 'confirm' => ['type' => 'boolean', 'title' => 'Confirm'], - 'rating' => ['type' => 'string', 'title' => 'Rating', 'enum' => ['1', '2', '3']], - ], - ]); - - $this->assertInstanceOf(StringSchemaDefinition::class, $schema->properties['name']); - $this->assertInstanceOf(NumberSchemaDefinition::class, $schema->properties['age']); - $this->assertInstanceOf(BooleanSchemaDefinition::class, $schema->properties['confirm']); - $this->assertInstanceOf(EnumSchemaDefinition::class, $schema->properties['rating']); - } - - public function testFromArrayWithMissingPropertyType(): void - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Missing or invalid "type"'); - - /* @phpstan-ignore argument.type */ - ElicitationSchema::fromArray([ - 'properties' => [ - 'name' => ['title' => 'Name'], - ], - ]); - } - - public function testFromArrayWithUnsupportedPropertyType(): void - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Unsupported type "object"'); - - ElicitationSchema::fromArray([ - 'properties' => [ - 'name' => ['type' => 'object', 'title' => 'Name'], - ], - ]); - } - - public function testFromArrayWithArrayTypeMissingItems(): void - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Array type must have "items" with either "enum" or "anyOf"'); - - ElicitationSchema::fromArray([ - 'properties' => [ - 'tags' => ['type' => 'array', 'title' => 'Tags'], - ], - ]); - } - - public function testFromArrayWithNewEnumTypes(): void - { - $schema = ElicitationSchema::fromArray([ - 'properties' => [ - 'titledSingle' => [ - 'type' => 'string', - 'title' => 'Titled Single', - 'oneOf' => [ - ['const' => 'a', 'title' => 'Option A'], - ['const' => 'b', 'title' => 'Option B'], - ], - ], - 'multiSelect' => [ - 'type' => 'array', - 'title' => 'Multi Select', - 'items' => ['type' => 'string', 'enum' => ['x', 'y']], - ], - 'titledMulti' => [ - 'type' => 'array', - 'title' => 'Titled Multi', - 'items' => [ - 'anyOf' => [ - ['const' => 'c', 'title' => 'Option C'], - ['const' => 'd', 'title' => 'Option D'], - ], - ], - ], - ], - ]); - - $this->assertInstanceOf(TitledEnumSchemaDefinition::class, $schema->properties['titledSingle']); - $this->assertInstanceOf(MultiSelectEnumSchemaDefinition::class, $schema->properties['multiSelect']); - $this->assertInstanceOf(TitledMultiSelectEnumSchemaDefinition::class, $schema->properties['titledMulti']); - } - - public function testFromArrayJsonSerializeRoundTripWithAllTypes(): void - { - $schema = new ElicitationSchema( - [ - 'name' => new StringSchemaDefinition('Name'), - 'rating' => new EnumSchemaDefinition('Rating', ['1', '2', '3']), - 'titledSingle' => new TitledEnumSchemaDefinition('Titled', [ - ['const' => 'a', 'title' => 'A'], - ]), - 'tags' => new MultiSelectEnumSchemaDefinition('Tags', ['x', 'y']), - 'titledMulti' => new TitledMultiSelectEnumSchemaDefinition('Multi', [ - ['const' => 'c', 'title' => 'C'], - ]), - ], - ['name'], - ); - - $serialized = $schema->jsonSerialize(); - $restored = ElicitationSchema::fromArray($serialized); - - $this->assertSame($serialized, $restored->jsonSerialize()); - } - - public function testJsonSerializeWithMinimalParams(): void - { - $schema = new ElicitationSchema([ - 'name' => new StringSchemaDefinition('Name'), - ]); - - $result = $schema->jsonSerialize(); - - $this->assertSame('object', $result['type']); - $this->assertArrayHasKey('name', $result['properties']); - $this->assertSame('string', $result['properties']['name']['type']); - $this->assertArrayNotHasKey('required', $result); - } - - public function testJsonSerializeWithRequiredFields(): void - { - $schema = new ElicitationSchema( - [ - 'name' => new StringSchemaDefinition('Name'), - 'email' => new StringSchemaDefinition('Email'), - ], - ['name'], - ); - - $result = $schema->jsonSerialize(); - - $this->assertSame(['name'], $result['required']); - } - - public function testJsonSerializeWithFullSchema(): void - { - $schema = new ElicitationSchema( - [ - 'name' => new StringSchemaDefinition('Full Name', description: 'Your full name'), - 'age' => new NumberSchemaDefinition('Age', integerOnly: true, minimum: 0, maximum: 150), - 'subscribe' => new BooleanSchemaDefinition('Subscribe', default: false), - ], - ['name', 'age'], - ); - - $result = $schema->jsonSerialize(); - - $this->assertSame('object', $result['type']); - $this->assertCount(3, $result['properties']); - $this->assertSame(['name', 'age'], $result['required']); - - $this->assertSame('string', $result['properties']['name']['type']); - $this->assertSame('Full Name', $result['properties']['name']['title']); - $this->assertSame('Your full name', $result['properties']['name']['description']); - - $this->assertSame('integer', $result['properties']['age']['type']); - $this->assertSame(0, $result['properties']['age']['minimum']); - $this->assertSame(150, $result['properties']['age']['maximum']); - - $this->assertSame('boolean', $result['properties']['subscribe']['type']); - $this->assertFalse($result['properties']['subscribe']['default']); - } -} diff --git a/tests/Unit/Schema/Elicitation/EnumSchemaDefinitionTest.php b/tests/Unit/Schema/Elicitation/EnumSchemaDefinitionTest.php deleted file mode 100644 index 1bd55ccd..00000000 --- a/tests/Unit/Schema/Elicitation/EnumSchemaDefinitionTest.php +++ /dev/null @@ -1,165 +0,0 @@ -assertSame('Rating', $schema->title); - $this->assertSame(['1', '2', '3', '4', '5'], $schema->enum); - $this->assertNull($schema->description); - $this->assertNull($schema->default); - $this->assertNull($schema->enumNames); - } - - public function testConstructorWithAllParams(): void - { - $schema = new EnumSchemaDefinition( - title: 'Satisfaction', - enum: ['poor', 'fair', 'good', 'excellent'], - description: 'Rate your satisfaction', - default: 'good', - enumNames: ['Poor', 'Fair', 'Good', 'Excellent'], - ); - - $this->assertSame('Satisfaction', $schema->title); - $this->assertSame(['poor', 'fair', 'good', 'excellent'], $schema->enum); - $this->assertSame('Rate your satisfaction', $schema->description); - $this->assertSame('good', $schema->default); - $this->assertSame(['Poor', 'Fair', 'Good', 'Excellent'], $schema->enumNames); - } - - public function testConstructorWithEmptyEnum(): void - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('enum array must not be empty'); - - new EnumSchemaDefinition('Test', []); - } - - public function testConstructorWithNonStringEnumValue(): void - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('All enum values must be strings'); - - /* @phpstan-ignore argument.type */ - new EnumSchemaDefinition('Test', ['a', 1, 'b']); - } - - public function testConstructorWithEnumNamesMismatch(): void - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('enumNames length must match enum length'); - - new EnumSchemaDefinition( - title: 'Test', - enum: ['a', 'b', 'c'], - enumNames: ['A', 'B'], - ); - } - - public function testConstructorWithInvalidDefault(): void - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Default value "invalid" is not in the enum array'); - - new EnumSchemaDefinition( - title: 'Test', - enum: ['a', 'b', 'c'], - default: 'invalid', - ); - } - - public function testFromArrayWithMinimalParams(): void - { - $schema = EnumSchemaDefinition::fromArray([ - 'title' => 'Rating', - 'enum' => ['1', '2', '3'], - ]); - - $this->assertSame('Rating', $schema->title); - $this->assertSame(['1', '2', '3'], $schema->enum); - } - - public function testFromArrayWithAllParams(): void - { - $schema = EnumSchemaDefinition::fromArray([ - 'title' => 'Satisfaction', - 'enum' => ['poor', 'fair', 'good'], - 'description' => 'Rate your satisfaction', - 'default' => 'fair', - 'enumNames' => ['Poor', 'Fair', 'Good'], - ]); - - $this->assertSame('Satisfaction', $schema->title); - $this->assertSame(['poor', 'fair', 'good'], $schema->enum); - $this->assertSame('Rate your satisfaction', $schema->description); - $this->assertSame('fair', $schema->default); - $this->assertSame(['Poor', 'Fair', 'Good'], $schema->enumNames); - } - - public function testFromArrayWithMissingTitle(): void - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Missing or invalid "title"'); - - /* @phpstan-ignore argument.type */ - EnumSchemaDefinition::fromArray(['enum' => ['a', 'b']]); - } - - public function testFromArrayWithMissingEnum(): void - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Missing or invalid "enum"'); - - /* @phpstan-ignore argument.type */ - EnumSchemaDefinition::fromArray(['title' => 'Test']); - } - - public function testJsonSerializeWithMinimalParams(): void - { - $schema = new EnumSchemaDefinition('Rating', ['1', '2', '3']); - - $this->assertSame([ - 'type' => 'string', - 'title' => 'Rating', - 'enum' => ['1', '2', '3'], - ], $schema->jsonSerialize()); - } - - public function testJsonSerializeWithAllParams(): void - { - $schema = new EnumSchemaDefinition( - title: 'Satisfaction', - enum: ['poor', 'fair', 'good'], - description: 'Rate your satisfaction', - default: 'fair', - enumNames: ['Poor', 'Fair', 'Good'], - ); - - $this->assertSame([ - 'type' => 'string', - 'title' => 'Satisfaction', - 'enum' => ['poor', 'fair', 'good'], - 'description' => 'Rate your satisfaction', - 'default' => 'fair', - 'enumNames' => ['Poor', 'Fair', 'Good'], - ], $schema->jsonSerialize()); - } -} diff --git a/tests/Unit/Schema/Elicitation/MultiSelectEnumSchemaDefinitionTest.php b/tests/Unit/Schema/Elicitation/MultiSelectEnumSchemaDefinitionTest.php deleted file mode 100644 index 167ad08e..00000000 --- a/tests/Unit/Schema/Elicitation/MultiSelectEnumSchemaDefinitionTest.php +++ /dev/null @@ -1,218 +0,0 @@ -assertSame('Tags', $schema->title); - $this->assertSame(['php', 'js', 'go'], $schema->enum); - $this->assertNull($schema->description); - $this->assertNull($schema->default); - $this->assertNull($schema->minItems); - $this->assertNull($schema->maxItems); - } - - public function testConstructorWithAllParams(): void - { - $schema = new MultiSelectEnumSchemaDefinition( - title: 'Tags', - enum: ['php', 'js', 'go'], - description: 'Select languages', - default: ['php'], - minItems: 1, - maxItems: 3, - ); - - $this->assertSame('Tags', $schema->title); - $this->assertSame(['php', 'js', 'go'], $schema->enum); - $this->assertSame('Select languages', $schema->description); - $this->assertSame(['php'], $schema->default); - $this->assertSame(1, $schema->minItems); - $this->assertSame(3, $schema->maxItems); - } - - public function testConstructorWithEmptyEnum(): void - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('enum array must not be empty'); - - new MultiSelectEnumSchemaDefinition('Test', []); - } - - public function testConstructorWithNonStringEnumValue(): void - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('All enum values must be strings'); - - /* @phpstan-ignore argument.type */ - new MultiSelectEnumSchemaDefinition('Test', ['a', 1, 'b']); - } - - public function testConstructorWithNegativeMinItems(): void - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('minItems must be non-negative'); - - new MultiSelectEnumSchemaDefinition('Test', ['a'], minItems: -1); - } - - public function testConstructorWithNegativeMaxItems(): void - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('maxItems must be non-negative'); - - new MultiSelectEnumSchemaDefinition('Test', ['a'], maxItems: -1); - } - - public function testConstructorWithMinItemsGreaterThanMaxItems(): void - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('minItems cannot be greater than maxItems'); - - new MultiSelectEnumSchemaDefinition('Test', ['a', 'b'], minItems: 3, maxItems: 1); - } - - public function testConstructorWithInvalidDefault(): void - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Default value "invalid" is not in the enum array'); - - new MultiSelectEnumSchemaDefinition('Test', ['a', 'b'], default: ['invalid']); - } - - public function testFromArrayWithMinimalParams(): void - { - $schema = MultiSelectEnumSchemaDefinition::fromArray([ - 'title' => 'Tags', - 'items' => [ - 'type' => 'string', - 'enum' => ['php', 'js', 'go'], - ], - ]); - - $this->assertSame('Tags', $schema->title); - $this->assertSame(['php', 'js', 'go'], $schema->enum); - $this->assertNull($schema->description); - $this->assertNull($schema->default); - $this->assertNull($schema->minItems); - $this->assertNull($schema->maxItems); - } - - public function testFromArrayWithAllParams(): void - { - $schema = MultiSelectEnumSchemaDefinition::fromArray([ - 'title' => 'Tags', - 'description' => 'Select languages', - 'default' => ['php'], - 'minItems' => 1, - 'maxItems' => 3, - 'items' => [ - 'type' => 'string', - 'enum' => ['php', 'js', 'go'], - ], - ]); - - $this->assertSame('Tags', $schema->title); - $this->assertSame(['php', 'js', 'go'], $schema->enum); - $this->assertSame('Select languages', $schema->description); - $this->assertSame(['php'], $schema->default); - $this->assertSame(1, $schema->minItems); - $this->assertSame(3, $schema->maxItems); - } - - public function testFromArrayWithMissingTitle(): void - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Missing or invalid "title"'); - - /* @phpstan-ignore argument.type */ - MultiSelectEnumSchemaDefinition::fromArray([ - 'items' => ['type' => 'string', 'enum' => ['a']], - ]); - } - - public function testFromArrayWithMissingItemsEnum(): void - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Missing or invalid "items.enum"'); - - /* @phpstan-ignore argument.type */ - MultiSelectEnumSchemaDefinition::fromArray([ - 'title' => 'Test', - 'items' => ['type' => 'string'], - ]); - } - - public function testJsonSerializeWithMinimalParams(): void - { - $schema = new MultiSelectEnumSchemaDefinition('Tags', ['php', 'js', 'go']); - - $this->assertSame([ - 'type' => 'array', - 'title' => 'Tags', - 'items' => [ - 'type' => 'string', - 'enum' => ['php', 'js', 'go'], - ], - ], $schema->jsonSerialize()); - } - - public function testJsonSerializeWithAllParams(): void - { - $schema = new MultiSelectEnumSchemaDefinition( - title: 'Tags', - enum: ['php', 'js', 'go'], - description: 'Select languages', - default: ['php'], - minItems: 1, - maxItems: 3, - ); - - $this->assertSame([ - 'type' => 'array', - 'title' => 'Tags', - 'description' => 'Select languages', - 'items' => [ - 'type' => 'string', - 'enum' => ['php', 'js', 'go'], - ], - 'default' => ['php'], - 'minItems' => 1, - 'maxItems' => 3, - ], $schema->jsonSerialize()); - } - - public function testFromArrayJsonSerializeRoundTrip(): void - { - $original = new MultiSelectEnumSchemaDefinition( - title: 'Tags', - enum: ['php', 'js', 'go'], - description: 'Select languages', - default: ['php', 'go'], - minItems: 1, - maxItems: 3, - ); - - $serialized = $original->jsonSerialize(); - $restored = MultiSelectEnumSchemaDefinition::fromArray($serialized); - - $this->assertSame($serialized, $restored->jsonSerialize()); - } -} diff --git a/tests/Unit/Schema/Elicitation/NumberSchemaDefinitionTest.php b/tests/Unit/Schema/Elicitation/NumberSchemaDefinitionTest.php deleted file mode 100644 index 8ebdee74..00000000 --- a/tests/Unit/Schema/Elicitation/NumberSchemaDefinitionTest.php +++ /dev/null @@ -1,191 +0,0 @@ -assertSame('Age', $schema->title); - $this->assertFalse($schema->integerOnly); - $this->assertNull($schema->description); - $this->assertNull($schema->default); - $this->assertNull($schema->minimum); - $this->assertNull($schema->maximum); - } - - public function testConstructorWithAllParams(): void - { - $schema = new NumberSchemaDefinition( - title: 'Party Size', - integerOnly: true, - description: 'Number of guests', - default: 2, - minimum: 1, - maximum: 10, - ); - - $this->assertSame('Party Size', $schema->title); - $this->assertTrue($schema->integerOnly); - $this->assertSame('Number of guests', $schema->description); - $this->assertSame(2, $schema->default); - $this->assertSame(1, $schema->minimum); - $this->assertSame(10, $schema->maximum); - } - - public function testConstructorWithFloatValues(): void - { - $schema = new NumberSchemaDefinition( - title: 'Temperature', - default: 36.5, - minimum: 35.0, - maximum: 42.0, - ); - - $this->assertSame(36.5, $schema->default); - $this->assertSame(35.0, $schema->minimum); - $this->assertSame(42.0, $schema->maximum); - } - - public function testConstructorWithMinimumGreaterThanMaximum(): void - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('minimum cannot be greater than maximum'); - - new NumberSchemaDefinition('Test', minimum: 10, maximum: 5); - } - - public function testConstructorWithDefaultBelowMinimum(): void - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('default value cannot be less than minimum'); - - new NumberSchemaDefinition('Test', default: 5, minimum: 10); - } - - public function testConstructorWithDefaultAboveMaximum(): void - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('default value cannot be greater than maximum'); - - new NumberSchemaDefinition('Test', default: 15, maximum: 10); - } - - public function testConstructorWithNonIntegerDefaultWhenIntegerOnly(): void - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('default value must be an integer when integerOnly is true'); - - new NumberSchemaDefinition('Test', integerOnly: true, default: 5.5); - } - - public function testFromArrayWithIntegerType(): void - { - $schema = NumberSchemaDefinition::fromArray([ - 'type' => 'integer', - 'title' => 'Count', - ]); - - $this->assertTrue($schema->integerOnly); - } - - public function testFromArrayWithNumberType(): void - { - $schema = NumberSchemaDefinition::fromArray([ - 'type' => 'number', - 'title' => 'Price', - ]); - - $this->assertFalse($schema->integerOnly); - } - - public function testFromArrayWithAllParams(): void - { - $schema = NumberSchemaDefinition::fromArray([ - 'type' => 'integer', - 'title' => 'Party Size', - 'description' => 'Number of guests', - 'default' => 2, - 'minimum' => 1, - 'maximum' => 10, - ]); - - $this->assertSame('Party Size', $schema->title); - $this->assertTrue($schema->integerOnly); - $this->assertSame('Number of guests', $schema->description); - $this->assertSame(2, $schema->default); - $this->assertSame(1, $schema->minimum); - $this->assertSame(10, $schema->maximum); - } - - public function testFromArrayWithMissingTitle(): void - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Missing or invalid "title"'); - - /* @phpstan-ignore argument.type */ - NumberSchemaDefinition::fromArray(['type' => 'integer']); - } - - public function testJsonSerializeAsInteger(): void - { - $schema = new NumberSchemaDefinition( - title: 'Count', - integerOnly: true, - ); - - $this->assertSame([ - 'type' => 'integer', - 'title' => 'Count', - ], $schema->jsonSerialize()); - } - - public function testJsonSerializeAsNumber(): void - { - $schema = new NumberSchemaDefinition( - title: 'Price', - integerOnly: false, - ); - - $this->assertSame([ - 'type' => 'number', - 'title' => 'Price', - ], $schema->jsonSerialize()); - } - - public function testJsonSerializeWithAllParams(): void - { - $schema = new NumberSchemaDefinition( - title: 'Party Size', - integerOnly: true, - description: 'Number of guests', - default: 2, - minimum: 1, - maximum: 10, - ); - - $this->assertSame([ - 'type' => 'integer', - 'title' => 'Party Size', - 'description' => 'Number of guests', - 'default' => 2, - 'minimum' => 1, - 'maximum' => 10, - ], $schema->jsonSerialize()); - } -} diff --git a/tests/Unit/Schema/Elicitation/StringSchemaDefinitionTest.php b/tests/Unit/Schema/Elicitation/StringSchemaDefinitionTest.php deleted file mode 100644 index dbb3277d..00000000 --- a/tests/Unit/Schema/Elicitation/StringSchemaDefinitionTest.php +++ /dev/null @@ -1,157 +0,0 @@ -assertSame('Name', $schema->title); - $this->assertNull($schema->description); - $this->assertNull($schema->default); - $this->assertNull($schema->format); - $this->assertNull($schema->minLength); - $this->assertNull($schema->maxLength); - } - - public function testConstructorWithAllParams(): void - { - $schema = new StringSchemaDefinition( - title: 'Email Address', - description: 'Your primary email', - default: 'user@example.com', - format: 'email', - minLength: 5, - maxLength: 100, - ); - - $this->assertSame('Email Address', $schema->title); - $this->assertSame('Your primary email', $schema->description); - $this->assertSame('user@example.com', $schema->default); - $this->assertSame('email', $schema->format); - $this->assertSame(5, $schema->minLength); - $this->assertSame(100, $schema->maxLength); - } - - public function testConstructorWithValidFormats(): void - { - foreach (['date', 'date-time', 'email', 'uri'] as $format) { - $schema = new StringSchemaDefinition('Test', format: $format); - $this->assertSame($format, $schema->format); - } - } - - public function testConstructorWithInvalidFormat(): void - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Invalid format "invalid"'); - - new StringSchemaDefinition('Test', format: 'invalid'); - } - - public function testConstructorWithNegativeMinLength(): void - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('minLength must be non-negative'); - - new StringSchemaDefinition('Test', minLength: -1); - } - - public function testConstructorWithNegativeMaxLength(): void - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('maxLength must be non-negative'); - - new StringSchemaDefinition('Test', maxLength: -1); - } - - public function testConstructorWithMinLengthGreaterThanMaxLength(): void - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('minLength cannot be greater than maxLength'); - - new StringSchemaDefinition('Test', minLength: 10, maxLength: 5); - } - - public function testFromArrayWithMinimalParams(): void - { - $schema = StringSchemaDefinition::fromArray(['title' => 'Name']); - - $this->assertSame('Name', $schema->title); - } - - public function testFromArrayWithAllParams(): void - { - $schema = StringSchemaDefinition::fromArray([ - 'title' => 'Email Address', - 'description' => 'Your primary email', - 'default' => 'user@example.com', - 'format' => 'email', - 'minLength' => 5, - 'maxLength' => 100, - ]); - - $this->assertSame('Email Address', $schema->title); - $this->assertSame('Your primary email', $schema->description); - $this->assertSame('user@example.com', $schema->default); - $this->assertSame('email', $schema->format); - $this->assertSame(5, $schema->minLength); - $this->assertSame(100, $schema->maxLength); - } - - public function testFromArrayWithMissingTitle(): void - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Missing or invalid "title"'); - - /* @phpstan-ignore argument.type */ - StringSchemaDefinition::fromArray([]); - } - - public function testJsonSerializeWithMinimalParams(): void - { - $schema = new StringSchemaDefinition('Name'); - - $this->assertSame([ - 'type' => 'string', - 'title' => 'Name', - ], $schema->jsonSerialize()); - } - - public function testJsonSerializeWithAllParams(): void - { - $schema = new StringSchemaDefinition( - title: 'Email Address', - description: 'Your primary email', - default: 'user@example.com', - format: 'email', - minLength: 5, - maxLength: 100, - ); - - $this->assertSame([ - 'type' => 'string', - 'title' => 'Email Address', - 'description' => 'Your primary email', - 'default' => 'user@example.com', - 'format' => 'email', - 'minLength' => 5, - 'maxLength' => 100, - ], $schema->jsonSerialize()); - } -} diff --git a/tests/Unit/Schema/Elicitation/TitledEnumSchemaDefinitionTest.php b/tests/Unit/Schema/Elicitation/TitledEnumSchemaDefinitionTest.php deleted file mode 100644 index f8c706f0..00000000 --- a/tests/Unit/Schema/Elicitation/TitledEnumSchemaDefinitionTest.php +++ /dev/null @@ -1,200 +0,0 @@ - 'a', 'title' => 'Option A'], - ['const' => 'b', 'title' => 'Option B'], - ]; - $schema = new TitledEnumSchemaDefinition('Pick one', $oneOf); - - $this->assertSame('Pick one', $schema->title); - $this->assertSame($oneOf, $schema->oneOf); - $this->assertNull($schema->description); - $this->assertNull($schema->default); - } - - public function testConstructorWithAllParams(): void - { - $oneOf = [ - ['const' => 'a', 'title' => 'Option A'], - ['const' => 'b', 'title' => 'Option B'], - ]; - $schema = new TitledEnumSchemaDefinition( - title: 'Pick one', - oneOf: $oneOf, - description: 'Choose wisely', - default: 'b', - ); - - $this->assertSame('Pick one', $schema->title); - $this->assertSame($oneOf, $schema->oneOf); - $this->assertSame('Choose wisely', $schema->description); - $this->assertSame('b', $schema->default); - } - - public function testConstructorWithEmptyOneOf(): void - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('oneOf array must not be empty'); - - new TitledEnumSchemaDefinition('Test', []); - } - - public function testConstructorWithMissingConst(): void - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Each oneOf item must have a string "const" property'); - - /* @phpstan-ignore argument.type */ - new TitledEnumSchemaDefinition('Test', [['title' => 'A']]); - } - - public function testConstructorWithMissingTitle(): void - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Each oneOf item must have a string "title" property'); - - /* @phpstan-ignore argument.type */ - new TitledEnumSchemaDefinition('Test', [['const' => 'a']]); - } - - public function testConstructorWithInvalidDefault(): void - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Default value "invalid" is not in the oneOf const values'); - - new TitledEnumSchemaDefinition( - title: 'Test', - oneOf: [['const' => 'a', 'title' => 'A']], - default: 'invalid', - ); - } - - public function testFromArrayWithMinimalParams(): void - { - $oneOf = [ - ['const' => 'a', 'title' => 'Option A'], - ['const' => 'b', 'title' => 'Option B'], - ]; - $schema = TitledEnumSchemaDefinition::fromArray([ - 'title' => 'Pick one', - 'oneOf' => $oneOf, - ]); - - $this->assertSame('Pick one', $schema->title); - $this->assertSame($oneOf, $schema->oneOf); - $this->assertNull($schema->description); - $this->assertNull($schema->default); - } - - public function testFromArrayWithAllParams(): void - { - $oneOf = [ - ['const' => 'a', 'title' => 'Option A'], - ['const' => 'b', 'title' => 'Option B'], - ]; - $schema = TitledEnumSchemaDefinition::fromArray([ - 'title' => 'Pick one', - 'oneOf' => $oneOf, - 'description' => 'Choose wisely', - 'default' => 'b', - ]); - - $this->assertSame('Pick one', $schema->title); - $this->assertSame($oneOf, $schema->oneOf); - $this->assertSame('Choose wisely', $schema->description); - $this->assertSame('b', $schema->default); - } - - public function testFromArrayWithMissingTitle(): void - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Missing or invalid "title"'); - - /* @phpstan-ignore argument.type */ - TitledEnumSchemaDefinition::fromArray(['oneOf' => [['const' => 'a', 'title' => 'A']]]); - } - - public function testFromArrayWithMissingOneOf(): void - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Missing or invalid "oneOf"'); - - /* @phpstan-ignore argument.type */ - TitledEnumSchemaDefinition::fromArray(['title' => 'Test']); - } - - public function testJsonSerializeWithMinimalParams(): void - { - $oneOf = [ - ['const' => 'a', 'title' => 'Option A'], - ['const' => 'b', 'title' => 'Option B'], - ]; - $schema = new TitledEnumSchemaDefinition('Pick one', $oneOf); - - $this->assertSame([ - 'type' => 'string', - 'title' => 'Pick one', - 'oneOf' => $oneOf, - ], $schema->jsonSerialize()); - } - - public function testJsonSerializeWithAllParams(): void - { - $oneOf = [ - ['const' => 'a', 'title' => 'Option A'], - ['const' => 'b', 'title' => 'Option B'], - ]; - $schema = new TitledEnumSchemaDefinition( - title: 'Pick one', - oneOf: $oneOf, - description: 'Choose wisely', - default: 'b', - ); - - $this->assertSame([ - 'type' => 'string', - 'title' => 'Pick one', - 'description' => 'Choose wisely', - 'oneOf' => $oneOf, - 'default' => 'b', - ], $schema->jsonSerialize()); - } - - public function testFromArrayJsonSerializeRoundTrip(): void - { - $oneOf = [ - ['const' => 'a', 'title' => 'Option A'], - ['const' => 'b', 'title' => 'Option B'], - ]; - $original = new TitledEnumSchemaDefinition( - title: 'Pick one', - oneOf: $oneOf, - description: 'Choose wisely', - default: 'b', - ); - - $serialized = $original->jsonSerialize(); - $restored = TitledEnumSchemaDefinition::fromArray($serialized); - - $this->assertSame($serialized, $restored->jsonSerialize()); - } -} diff --git a/tests/Unit/Schema/Elicitation/TitledMultiSelectEnumSchemaDefinitionTest.php b/tests/Unit/Schema/Elicitation/TitledMultiSelectEnumSchemaDefinitionTest.php deleted file mode 100644 index 6c1b776e..00000000 --- a/tests/Unit/Schema/Elicitation/TitledMultiSelectEnumSchemaDefinitionTest.php +++ /dev/null @@ -1,253 +0,0 @@ - 'a', 'title' => 'Option A'], - ['const' => 'b', 'title' => 'Option B'], - ]; - $schema = new TitledMultiSelectEnumSchemaDefinition('Pick many', $anyOf); - - $this->assertSame('Pick many', $schema->title); - $this->assertSame($anyOf, $schema->anyOf); - $this->assertNull($schema->description); - $this->assertNull($schema->default); - $this->assertNull($schema->minItems); - $this->assertNull($schema->maxItems); - } - - public function testConstructorWithAllParams(): void - { - $anyOf = [ - ['const' => 'a', 'title' => 'Option A'], - ['const' => 'b', 'title' => 'Option B'], - ['const' => 'c', 'title' => 'Option C'], - ]; - $schema = new TitledMultiSelectEnumSchemaDefinition( - title: 'Pick many', - anyOf: $anyOf, - description: 'Select all that apply', - default: ['a', 'c'], - minItems: 1, - maxItems: 3, - ); - - $this->assertSame('Pick many', $schema->title); - $this->assertSame($anyOf, $schema->anyOf); - $this->assertSame('Select all that apply', $schema->description); - $this->assertSame(['a', 'c'], $schema->default); - $this->assertSame(1, $schema->minItems); - $this->assertSame(3, $schema->maxItems); - } - - public function testConstructorWithEmptyAnyOf(): void - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('anyOf array must not be empty'); - - new TitledMultiSelectEnumSchemaDefinition('Test', []); - } - - public function testConstructorWithMissingConst(): void - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Each anyOf item must have a string "const" property'); - - /* @phpstan-ignore argument.type */ - new TitledMultiSelectEnumSchemaDefinition('Test', [['title' => 'A']]); - } - - public function testConstructorWithMissingTitle(): void - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Each anyOf item must have a string "title" property'); - - /* @phpstan-ignore argument.type */ - new TitledMultiSelectEnumSchemaDefinition('Test', [['const' => 'a']]); - } - - public function testConstructorWithNegativeMinItems(): void - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('minItems must be non-negative'); - - new TitledMultiSelectEnumSchemaDefinition('Test', [['const' => 'a', 'title' => 'A']], minItems: -1); - } - - public function testConstructorWithNegativeMaxItems(): void - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('maxItems must be non-negative'); - - new TitledMultiSelectEnumSchemaDefinition('Test', [['const' => 'a', 'title' => 'A']], maxItems: -1); - } - - public function testConstructorWithMinItemsGreaterThanMaxItems(): void - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('minItems cannot be greater than maxItems'); - - new TitledMultiSelectEnumSchemaDefinition( - 'Test', - [['const' => 'a', 'title' => 'A'], ['const' => 'b', 'title' => 'B']], - minItems: 3, - maxItems: 1, - ); - } - - public function testConstructorWithInvalidDefault(): void - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Default value "invalid" is not in the anyOf const values'); - - new TitledMultiSelectEnumSchemaDefinition( - 'Test', - [['const' => 'a', 'title' => 'A']], - default: ['invalid'], - ); - } - - public function testFromArrayWithMinimalParams(): void - { - $anyOf = [ - ['const' => 'a', 'title' => 'Option A'], - ['const' => 'b', 'title' => 'Option B'], - ]; - $schema = TitledMultiSelectEnumSchemaDefinition::fromArray([ - 'title' => 'Pick many', - 'items' => ['anyOf' => $anyOf], - ]); - - $this->assertSame('Pick many', $schema->title); - $this->assertSame($anyOf, $schema->anyOf); - $this->assertNull($schema->description); - $this->assertNull($schema->default); - $this->assertNull($schema->minItems); - $this->assertNull($schema->maxItems); - } - - public function testFromArrayWithAllParams(): void - { - $anyOf = [ - ['const' => 'a', 'title' => 'Option A'], - ['const' => 'b', 'title' => 'Option B'], - ]; - $schema = TitledMultiSelectEnumSchemaDefinition::fromArray([ - 'title' => 'Pick many', - 'description' => 'Select all that apply', - 'default' => ['a'], - 'minItems' => 1, - 'maxItems' => 2, - 'items' => ['anyOf' => $anyOf], - ]); - - $this->assertSame('Pick many', $schema->title); - $this->assertSame($anyOf, $schema->anyOf); - $this->assertSame('Select all that apply', $schema->description); - $this->assertSame(['a'], $schema->default); - $this->assertSame(1, $schema->minItems); - $this->assertSame(2, $schema->maxItems); - } - - public function testFromArrayWithMissingTitle(): void - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Missing or invalid "title"'); - - /* @phpstan-ignore argument.type */ - TitledMultiSelectEnumSchemaDefinition::fromArray([ - 'items' => ['anyOf' => [['const' => 'a', 'title' => 'A']]], - ]); - } - - public function testFromArrayWithMissingItemsAnyOf(): void - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Missing or invalid "items.anyOf"'); - - /* @phpstan-ignore argument.type */ - TitledMultiSelectEnumSchemaDefinition::fromArray([ - 'title' => 'Test', - 'items' => [], - ]); - } - - public function testJsonSerializeWithMinimalParams(): void - { - $anyOf = [ - ['const' => 'a', 'title' => 'Option A'], - ['const' => 'b', 'title' => 'Option B'], - ]; - $schema = new TitledMultiSelectEnumSchemaDefinition('Pick many', $anyOf); - - $this->assertSame([ - 'type' => 'array', - 'title' => 'Pick many', - 'items' => ['anyOf' => $anyOf], - ], $schema->jsonSerialize()); - } - - public function testJsonSerializeWithAllParams(): void - { - $anyOf = [ - ['const' => 'a', 'title' => 'Option A'], - ['const' => 'b', 'title' => 'Option B'], - ]; - $schema = new TitledMultiSelectEnumSchemaDefinition( - title: 'Pick many', - anyOf: $anyOf, - description: 'Select all that apply', - default: ['a'], - minItems: 1, - maxItems: 2, - ); - - $this->assertSame([ - 'type' => 'array', - 'title' => 'Pick many', - 'description' => 'Select all that apply', - 'items' => ['anyOf' => $anyOf], - 'default' => ['a'], - 'minItems' => 1, - 'maxItems' => 2, - ], $schema->jsonSerialize()); - } - - public function testFromArrayJsonSerializeRoundTrip(): void - { - $anyOf = [ - ['const' => 'a', 'title' => 'Option A'], - ['const' => 'b', 'title' => 'Option B'], - ]; - $original = new TitledMultiSelectEnumSchemaDefinition( - title: 'Pick many', - anyOf: $anyOf, - description: 'Select all that apply', - default: ['a', 'b'], - minItems: 1, - maxItems: 2, - ); - - $serialized = $original->jsonSerialize(); - $restored = TitledMultiSelectEnumSchemaDefinition::fromArray($serialized); - - $this->assertSame($serialized, $restored->jsonSerialize()); - } -} diff --git a/tests/Unit/Schema/Enum/ElicitActionTest.php b/tests/Unit/Schema/Enum/ElicitActionTest.php deleted file mode 100644 index 47ea4a6a..00000000 --- a/tests/Unit/Schema/Enum/ElicitActionTest.php +++ /dev/null @@ -1,50 +0,0 @@ -assertSame('accept', ElicitAction::Accept->value); - $this->assertSame('decline', ElicitAction::Decline->value); - $this->assertSame('cancel', ElicitAction::Cancel->value); - } - - public function testFromValidValues(): void - { - $this->assertSame(ElicitAction::Accept, ElicitAction::from('accept')); - $this->assertSame(ElicitAction::Decline, ElicitAction::from('decline')); - $this->assertSame(ElicitAction::Cancel, ElicitAction::from('cancel')); - } - - public function testFromInvalidValue(): void - { - $this->expectException(\ValueError::class); - ElicitAction::from('invalid'); - } - - public function testTryFromValidValues(): void - { - $this->assertSame(ElicitAction::Accept, ElicitAction::tryFrom('accept')); - $this->assertSame(ElicitAction::Decline, ElicitAction::tryFrom('decline')); - $this->assertSame(ElicitAction::Cancel, ElicitAction::tryFrom('cancel')); - } - - public function testTryFromInvalidValue(): void - { - $this->assertNull(ElicitAction::tryFrom('invalid')); - } -} diff --git a/tests/Unit/Schema/Enum/ProtocolVersionTest.php b/tests/Unit/Schema/Enum/ProtocolVersionTest.php deleted file mode 100644 index 00c50e93..00000000 --- a/tests/Unit/Schema/Enum/ProtocolVersionTest.php +++ /dev/null @@ -1,144 +0,0 @@ - $version) { - $this->assertTrue( - $version->isAtLeast($version), - \sprintf('%s should be at least itself.', $version->value), - ); - - foreach (\array_slice($cases, $index + 1) as $newer) { - $this->assertTrue( - $newer->isAtLeast($version), - \sprintf('%s is declared after %s and should compare newer.', $newer->value, $version->value), - ); - $this->assertFalse( - $version->isAtLeast($newer), - \sprintf('%s should not compare newer than %s.', $version->value, $newer->value), - ); - } - } - } - - #[TestDox('splits the known revisions into a handshake and a modern era')] - public function testEraSplitCoversEveryCaseExactlyOnce(): void - { - $handshake = ProtocolVersion::handshakeVersions(); - $modern = ProtocolVersion::modernVersions(); - - // A partition: every revision in exactly one era, in declaration order. - $this->assertSame(ProtocolVersion::cases(), [...$handshake, ...$modern]); - } - - #[TestDox('every known revision is assigned to an era on purpose')] - public function testEveryRevisionIsClassifiedExplicitly(): void - { - // Hand-maintained on purpose: a revision appended below FIRST_MODERN_VERSION - // is classified modern by accident, and every other assertion in this file - // stays green while it vanishes from the handshake negotiation. - $eras = [ - '2024-11-05' => false, - '2025-03-26' => false, - '2025-06-18' => false, - '2025-11-25' => false, - '2026-07-28' => true, - ]; - - $this->assertSame( - array_keys($eras), - array_map(static fn (ProtocolVersion $version): string => $version->value, ProtocolVersion::cases()), - 'A revision was added or removed: list it above with the era it belongs to.', - ); - - foreach (ProtocolVersion::cases() as $version) { - $this->assertSame( - $eras[$version->value], - $version->isModern(), - \sprintf('%s is in the wrong era; a handshake revision must be declared above %s.', $version->value, ProtocolVersion::FIRST_MODERN_VERSION->value), - ); - } - } - - #[TestDox('2026-07-28 opens the modern era')] - public function testModernEraStartsAt20260728(): void - { - $this->assertSame(ProtocolVersion::V2026_07_28, ProtocolVersion::FIRST_MODERN_VERSION); - - $this->assertFalse(ProtocolVersion::V2025_11_25->isModern()); - $this->assertTrue(ProtocolVersion::V2026_07_28->isModern()); - } - - #[TestDox('latestHandshake() stops short of the modern era')] - public function testLatestHandshakeStopsBeforeTheModernEra(): void - { - $this->assertSame(ProtocolVersion::V2025_11_25, ProtocolVersion::latestHandshake()); - $this->assertFalse(ProtocolVersion::latestHandshake()->isModern()); - } - - #[TestDox('handshake versions never contain a modern revision')] - public function testHandshakeVersionsExcludeModernRevisions(): void - { - foreach (ProtocolVersion::handshakeVersions() as $version) { - $this->assertFalse($version->isModern(), \sprintf('%s leaked into the handshake era.', $version->value)); - } - } - - #[TestDox('compares by declaration order, not by string value')] - #[DataProvider('provideVersionComparisons')] - public function testIsAtLeast(ProtocolVersion $version, ProtocolVersion $minimum, bool $expected): void - { - $this->assertSame($expected, $version->isAtLeast($minimum)); - } - - /** - * @return iterable - */ - public static function provideVersionComparisons(): iterable - { - yield 'equal' => [ProtocolVersion::V2025_06_18, ProtocolVersion::V2025_06_18, true]; - yield 'newer' => [ProtocolVersion::V2025_11_25, ProtocolVersion::V2025_06_18, true]; - yield 'older' => [ProtocolVersion::V2024_11_05, ProtocolVersion::V2025_03_26, false]; - yield 'across eras' => [ProtocolVersion::V2026_07_28, ProtocolVersion::V2024_11_05, true]; - yield 'oldest against newest' => [ProtocolVersion::V2024_11_05, ProtocolVersion::V2026_07_28, false]; - } - - #[TestDox('the header-absent default is the revision that introduced the header')] - public function testDefaultHeaderVersion(): void - { - $this->assertSame(ProtocolVersion::V2025_03_26, ProtocolVersion::DEFAULT_HEADER_VERSION); - } - - #[TestDox('SEP-2106 lifts the object-only rule for structuredContent')] - public function testRequiresObjectStructuredContent(): void - { - foreach (ProtocolVersion::handshakeVersions() as $version) { - $this->assertTrue($version->requiresObjectStructuredContent(), \sprintf('%s predates SEP-2106.', $version->value)); - } - - $this->assertFalse(ProtocolVersion::V2026_07_28->requiresObjectStructuredContent()); - } -} diff --git a/tests/Unit/Schema/Extension/Apps/McpAppsTest.php b/tests/Unit/Schema/Extension/Apps/McpAppsTest.php deleted file mode 100644 index ec0fd2ba..00000000 --- a/tests/Unit/Schema/Extension/Apps/McpAppsTest.php +++ /dev/null @@ -1,223 +0,0 @@ -assertSame('io.modelcontextprotocol/ui', $extension->getId()); - $this->assertSame(['mimeTypes' => ['text/html;profile=mcp-app']], $extension->getCapabilities()); - } - - public function testUiResourceCspSerialization(): void - { - $csp = new UiResourceCsp( - connectDomains: ['https://api.example.com'], - resourceDomains: ['https://cdn.example.com'], - frameDomains: ['https://embed.example.com'], - baseUriDomains: ['https://example.com'], - ); - - $serialized = $csp->jsonSerialize(); - - $this->assertSame(['https://api.example.com'], $serialized['connectDomains']); - $this->assertSame(['https://cdn.example.com'], $serialized['resourceDomains']); - $this->assertSame(['https://embed.example.com'], $serialized['frameDomains']); - $this->assertSame(['https://example.com'], $serialized['baseUriDomains']); - } - - public function testUiResourceCspOmitsNullFields(): void - { - $csp = new UiResourceCsp(connectDomains: ['https://api.example.com']); - - $serialized = $csp->jsonSerialize(); - - $this->assertArrayHasKey('connectDomains', $serialized); - $this->assertArrayNotHasKey('resourceDomains', $serialized); - $this->assertArrayNotHasKey('frameDomains', $serialized); - $this->assertArrayNotHasKey('baseUriDomains', $serialized); - } - - public function testUiResourceCspFromArray(): void - { - $csp = UiResourceCsp::fromArray([ - 'connectDomains' => ['https://api.example.com'], - 'frameDomains' => ['https://embed.example.com'], - ]); - - $this->assertSame(['https://api.example.com'], $csp->connectDomains); - $this->assertNull($csp->resourceDomains); - $this->assertSame(['https://embed.example.com'], $csp->frameDomains); - $this->assertNull($csp->baseUriDomains); - } - - public function testUiResourcePermissionsSerialization(): void - { - $perms = new UiResourcePermissions( - camera: true, - microphone: false, - geolocation: true, - clipboardWrite: false, - ); - - $serialized = $perms->jsonSerialize(); - - // Per spec, each requested permission is an empty object marker. - $this->assertEquals(new \stdClass(), $serialized['camera']); - $this->assertArrayNotHasKey('microphone', $serialized); - $this->assertEquals(new \stdClass(), $serialized['geolocation']); - $this->assertArrayNotHasKey('clipboardWrite', $serialized); - $this->assertSame('{"camera":{},"geolocation":{}}', json_encode($perms)); - } - - public function testUiResourcePermissionsOmitsUnrequestedFields(): void - { - $perms = new UiResourcePermissions(clipboardWrite: true); - - $serialized = $perms->jsonSerialize(); - - $this->assertArrayNotHasKey('camera', $serialized); - $this->assertArrayNotHasKey('microphone', $serialized); - $this->assertArrayNotHasKey('geolocation', $serialized); - $this->assertArrayHasKey('clipboardWrite', $serialized); - } - - public function testUiResourcePermissionsFromArray(): void - { - // Spec wire shape: presence indicates a request; the value is an empty object. - $perms = UiResourcePermissions::fromArray([ - 'camera' => [], - 'clipboardWrite' => [], - ]); - - $this->assertTrue($perms->camera); - $this->assertFalse($perms->microphone); - $this->assertFalse($perms->geolocation); - $this->assertTrue($perms->clipboardWrite); - } - - public function testUiResourcePermissionsFromArrayTreatsNullAsNotRequested(): void - { - // isset() rejects null values, so 'camera' => null must read as "not requested", - // not as "requested with a null marker". - // @phpstan-ignore-next-line — intentionally off-spec payload - $perms = UiResourcePermissions::fromArray(['camera' => null, 'geolocation' => []]); - - $this->assertFalse($perms->camera); - $this->assertTrue($perms->geolocation); - } - - public function testUiResourceContentMetaSerialization(): void - { - $meta = new UiResourceContentMeta( - csp: new UiResourceCsp(connectDomains: ['https://api.example.com']), - permissions: new UiResourcePermissions(clipboardWrite: true), - domain: 'example.com', - prefersBorder: true, - ); - - $serialized = $meta->jsonSerialize(); - - $this->assertArrayHasKey('csp', $serialized); - $this->assertArrayHasKey('permissions', $serialized); - $this->assertSame('example.com', $serialized['domain']); - $this->assertTrue($serialized['prefersBorder']); - } - - public function testUiResourceContentMetaOmitsNullFields(): void - { - $meta = new UiResourceContentMeta(prefersBorder: true); - - $serialized = $meta->jsonSerialize(); - - $this->assertArrayNotHasKey('csp', $serialized); - $this->assertArrayNotHasKey('permissions', $serialized); - $this->assertArrayNotHasKey('domain', $serialized); - $this->assertArrayHasKey('prefersBorder', $serialized); - } - - public function testUiResourceContentMetaFromArray(): void - { - $meta = UiResourceContentMeta::fromArray([ - 'csp' => ['connectDomains' => ['https://api.example.com']], - 'permissions' => ['clipboardWrite' => []], - 'domain' => 'example.com', - 'prefersBorder' => false, - ]); - - $this->assertInstanceOf(UiResourceCsp::class, $meta->csp); - $this->assertSame(['https://api.example.com'], $meta->csp->connectDomains); - $this->assertInstanceOf(UiResourcePermissions::class, $meta->permissions); - $this->assertTrue($meta->permissions->clipboardWrite); - $this->assertSame('example.com', $meta->domain); - $this->assertFalse($meta->prefersBorder); - } - - public function testUiToolMetaSerialization(): void - { - $meta = new UiToolMeta( - resourceUri: 'ui://my-app', - visibility: [ToolVisibility::Model, ToolVisibility::App], - ); - - $serialized = $meta->jsonSerialize(); - - $this->assertSame('ui://my-app', $serialized['resourceUri']); - $this->assertSame(['model', 'app'], $serialized['visibility']); - } - - public function testUiToolMetaOmitsNullFields(): void - { - $meta = new UiToolMeta(resourceUri: 'ui://my-app'); - - $serialized = $meta->jsonSerialize(); - - $this->assertArrayHasKey('resourceUri', $serialized); - $this->assertArrayNotHasKey('visibility', $serialized); - } - - public function testUiToolMetaFromArray(): void - { - $meta = UiToolMeta::fromArray([ - 'resourceUri' => 'ui://my-app', - 'visibility' => ['app'], - ]); - - $this->assertSame('ui://my-app', $meta->resourceUri); - $this->assertSame([ToolVisibility::App], $meta->visibility); - } - - public function testToolVisibilityEnum(): void - { - $this->assertSame('model', ToolVisibility::Model->value); - $this->assertSame('app', ToolVisibility::App->value); - } - - public function testResourceMarkerSerializesToEmptyObject(): void - { - $marker = McpApps::resourceMarker(); - - $this->assertEquals(new \stdClass(), $marker); - $this->assertSame('{}', json_encode($marker)); - } -} diff --git a/tests/Unit/Schema/Extension/CapabilitiesExtensionsTest.php b/tests/Unit/Schema/Extension/CapabilitiesExtensionsTest.php deleted file mode 100644 index d9661115..00000000 --- a/tests/Unit/Schema/Extension/CapabilitiesExtensionsTest.php +++ /dev/null @@ -1,232 +0,0 @@ - (new McpApps())->getCapabilities(), - ]; - - $caps = new ServerCapabilities( - tools: true, - resources: true, - extensions: $extensions, - ); - - $this->assertSame($extensions, $caps->extensions); - } - - public function testServerCapabilitiesExtensionsDefaultNull(): void - { - $caps = new ServerCapabilities(); - - $this->assertNull($caps->extensions); - } - - public function testServerCapabilitiesJsonSerializeWithExtensions(): void - { - $caps = new ServerCapabilities( - tools: true, - resources: true, - prompts: false, - extensions: [ - McpApps::EXTENSION_ID => (new McpApps())->getCapabilities(), - ], - ); - - $json = $caps->jsonSerialize(); - - $this->assertArrayHasKey('extensions', $json); - $this->assertObjectHasProperty(McpApps::EXTENSION_ID, $json['extensions']); - } - - public function testServerCapabilitiesJsonSerializeWithoutExtensions(): void - { - $caps = new ServerCapabilities(tools: true); - - $json = $caps->jsonSerialize(); - - $this->assertArrayNotHasKey('extensions', $json); - } - - public function testServerCapabilitiesFromArrayWithExtensions(): void - { - $data = [ - 'tools' => new \stdClass(), - 'extensions' => [ - McpApps::EXTENSION_ID => ['mimeTypes' => ['text/html;profile=mcp-app']], - ], - ]; - - $caps = ServerCapabilities::fromArray($data); - - $this->assertTrue($caps->tools); - $this->assertNotNull($caps->extensions); - $this->assertArrayHasKey(McpApps::EXTENSION_ID, $caps->extensions); - $this->assertSame(['text/html;profile=mcp-app'], $caps->extensions[McpApps::EXTENSION_ID]['mimeTypes']); - } - - public function testServerCapabilitiesFromArrayWithoutExtensions(): void - { - $caps = ServerCapabilities::fromArray(['tools' => new \stdClass()]); - - $this->assertNull($caps->extensions); - } - - public function testServerCapabilitiesFromArrayRejectsNonArrayExtensions(): void - { - // @phpstan-ignore-next-line — intentionally malformed payload - $caps = ServerCapabilities::fromArray(['tools' => new \stdClass(), 'extensions' => 'not-an-array']); - - $this->assertNull($caps->extensions); - } - - public function testServerCapabilitiesWithExtensionsMerges(): void - { - $caps = new ServerCapabilities( - tools: true, - extensions: ['a' => ['x' => 1], 'b' => ['y' => 2]], - ); - - $merged = $caps->withExtensions(['b' => ['y' => 99], 'c' => ['z' => 3]]); - - $this->assertSame(['x' => 1], $merged->extensions['a']); - $this->assertSame(['y' => 99], $merged->extensions['b'], 'new entry overrides existing id'); - $this->assertSame(['z' => 3], $merged->extensions['c']); - $this->assertSame(['a' => ['x' => 1], 'b' => ['y' => 2]], $caps->extensions, 'original is unchanged'); - } - - public function testClientCapabilitiesWithExtensions(): void - { - $extensions = [ - McpApps::EXTENSION_ID => (new McpApps())->getCapabilities(), - ]; - - $caps = new ClientCapabilities( - extensions: $extensions, - ); - - $this->assertSame($extensions, $caps->extensions); - } - - public function testClientCapabilitiesExtensionsDefaultNull(): void - { - $caps = new ClientCapabilities(); - - $this->assertNull($caps->extensions); - } - - public function testClientCapabilitiesJsonSerializeWithExtensions(): void - { - $caps = new ClientCapabilities( - extensions: [ - McpApps::EXTENSION_ID => (new McpApps())->getCapabilities(), - ], - ); - - $json = $caps->jsonSerialize(); - - $this->assertArrayHasKey('extensions', $json); - $this->assertObjectHasProperty(McpApps::EXTENSION_ID, $json['extensions']); - } - - public function testClientCapabilitiesJsonSerializeWithoutExtensions(): void - { - $caps = new ClientCapabilities(); - - $json = $caps->jsonSerialize(); - - // ClientCapabilities returns \stdClass when empty (so it serializes as `{}`, not `[]`). - if (\is_array($json)) { - $this->assertArrayNotHasKey('extensions', $json); - } else { - $this->assertObjectNotHasProperty('extensions', $json); - } - } - - public function testClientCapabilitiesFromArrayWithExtensions(): void - { - $data = [ - 'roots' => ['listChanged' => true], - 'extensions' => [ - McpApps::EXTENSION_ID => ['mimeTypes' => ['text/html;profile=mcp-app']], - ], - ]; - - $caps = ClientCapabilities::fromArray($data); - - $this->assertTrue($caps->roots); - $this->assertTrue($caps->rootsListChanged); - $this->assertNotNull($caps->extensions); - $this->assertArrayHasKey(McpApps::EXTENSION_ID, $caps->extensions); - } - - public function testClientCapabilitiesFromArrayWithoutExtensions(): void - { - $caps = ClientCapabilities::fromArray(['roots' => ['listChanged' => true]]); - - $this->assertNull($caps->extensions); - } - - public function testClientCapabilitiesFromArrayRejectsNonArrayExtensions(): void - { - // @phpstan-ignore-next-line — intentionally malformed payload - $caps = ClientCapabilities::fromArray(['roots' => ['listChanged' => true], 'extensions' => 'not-an-array']); - - $this->assertNull($caps->extensions); - } - - public function testBackwardCompatibilityServerCapabilities(): void - { - $caps = new ServerCapabilities( - tools: true, - toolsListChanged: false, - resources: true, - resourcesSubscribe: false, - resourcesListChanged: false, - prompts: true, - promptsListChanged: false, - logging: false, - completions: false, - experimental: null, - ); - - $this->assertNull($caps->extensions); - - $json = $caps->jsonSerialize(); - $this->assertArrayNotHasKey('extensions', $json); - } - - public function testBackwardCompatibilityClientCapabilities(): void - { - $caps = new ClientCapabilities( - roots: true, - rootsListChanged: true, - sampling: true, - elicitation: true, - experimental: null, - ); - - $this->assertNull($caps->extensions); - - $json = $caps->jsonSerialize(); - $this->assertArrayNotHasKey('extensions', $json); - } -} diff --git a/tests/Unit/Schema/IconTest.php b/tests/Unit/Schema/IconTest.php deleted file mode 100644 index 500a81b3..00000000 --- a/tests/Unit/Schema/IconTest.php +++ /dev/null @@ -1,88 +0,0 @@ -assertSame('https://www.php.net/images/logos/php-logo-white.svg', $icon->src); - $this->assertSame('image/svg+xml', $icon->mimeType); - $this->assertSame('any', $icon->sizes[0]); - } - - public function testConstructorWithMultipleSizes(): void - { - $icon = new Icon('https://example.com/icon.png', 'image/png', ['48x48', '96x96']); - - $this->assertCount(2, $icon->sizes); - $this->assertSame(['48x48', '96x96'], $icon->sizes); - } - - public function testConstructorWithAnySizes(): void - { - $icon = new Icon('https://example.com/icon.svg', 'image/png', ['any']); - - $this->assertSame(['any'], $icon->sizes); - } - - public function testConstructorWithNullOptionalFields(): void - { - $icon = new Icon('https://example.com/icon.png'); - - $this->assertSame('https://example.com/icon.png', $icon->src); - $this->assertNull($icon->mimeType); - $this->assertNull($icon->sizes); - } - - public function testInvalidSizesFormatThrowsException(): void - { - $this->expectException(InvalidArgumentException::class); - - new Icon('https://example.com/icon.png', 'image/png', ['invalid-size']); - } - - public function testInvalidPixelSizesFormatThrowsException(): void - { - $this->expectException(InvalidArgumentException::class); - - new Icon('https://example.com/icon.png', 'image/png', ['180x48x48']); - } - - public function testEmptySrcThrowsException(): void - { - $this->expectException(InvalidArgumentException::class); - - new Icon('', 'image/png', ['48x48']); - } - - public function testInvalidSrcThrowsException(): void - { - $this->expectException(InvalidArgumentException::class); - - new Icon('not-a-url', 'image/png', ['48x48']); - } - - public function testValidDataUriSrc(): void - { - $dataUri = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUA'; - $icon = new Icon($dataUri, 'image/png', ['48x48']); - - $this->assertSame($dataUri, $icon->src); - } -} diff --git a/tests/Unit/Schema/ImplementationTest.php b/tests/Unit/Schema/ImplementationTest.php deleted file mode 100644 index 1e19dea7..00000000 --- a/tests/Unit/Schema/ImplementationTest.php +++ /dev/null @@ -1,177 +0,0 @@ -assertSame('app', $implementation->name); - $this->assertSame('dev', $implementation->version); - $this->assertNull($implementation->description); - $this->assertNull($implementation->icons); - $this->assertNull($implementation->websiteUrl); - } - - public function testFromArrayWithMinimalData(): void - { - $implementation = Implementation::fromArray([ - 'name' => 'my-client', - 'version' => '1.2.3', - ]); - - $this->assertSame('my-client', $implementation->name); - $this->assertSame('1.2.3', $implementation->version); - $this->assertNull($implementation->description); - $this->assertNull($implementation->icons); - $this->assertNull($implementation->websiteUrl); - } - - public function testFromArrayWithAllFields(): void - { - $implementation = Implementation::fromArray([ - 'name' => 'my-client', - 'version' => '1.2.3', - 'description' => 'A test client', - 'icons' => [['src' => 'https://example.com/icon.png']], - 'websiteUrl' => 'https://example.com', - ]); - - $this->assertSame('my-client', $implementation->name); - $this->assertSame('1.2.3', $implementation->version); - $this->assertSame('A test client', $implementation->description); - $this->assertIsArray($implementation->icons); - $this->assertCount(1, $implementation->icons); - $this->assertSame('https://example.com', $implementation->websiteUrl); - } - - /** - * Regression test for #392: falsy-but-valid version strings such as "0" - * were rejected because empty('0') === true. - */ - public function testFromArrayAcceptsZeroStringVersion(): void - { - $implementation = Implementation::fromArray([ - 'name' => 'my-client', - 'version' => '0', - ]); - - $this->assertSame('0', $implementation->version); - } - - /** - * Regression test for #392: a name of "0" is a valid string and must be accepted. - */ - public function testFromArrayAcceptsZeroStringName(): void - { - $implementation = Implementation::fromArray([ - 'name' => '0', - 'version' => '1.0.0', - ]); - - $this->assertSame('0', $implementation->name); - } - - public function testFromArrayThrowsOnMissingName(): void - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Invalid or missing "name" in Implementation data.'); - - /* @phpstan-ignore argument.type */ - Implementation::fromArray(['version' => '1.0.0']); - } - - public function testFromArrayThrowsOnEmptyName(): void - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Invalid or missing "name" in Implementation data.'); - - Implementation::fromArray(['name' => '', 'version' => '1.0.0']); - } - - public function testFromArrayThrowsOnNonStringName(): void - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Invalid or missing "name" in Implementation data.'); - - /* @phpstan-ignore argument.type */ - Implementation::fromArray(['name' => 123, 'version' => '1.0.0']); - } - - public function testFromArrayThrowsOnMissingVersion(): void - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Invalid or missing "version" in Implementation data.'); - - /* @phpstan-ignore argument.type */ - Implementation::fromArray(['name' => 'my-client']); - } - - public function testFromArrayThrowsOnEmptyVersion(): void - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Invalid or missing "version" in Implementation data.'); - - Implementation::fromArray(['name' => 'my-client', 'version' => '']); - } - - public function testFromArrayThrowsOnNonStringVersion(): void - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Invalid or missing "version" in Implementation data.'); - - /* @phpstan-ignore argument.type */ - Implementation::fromArray(['name' => 'my-client', 'version' => 1]); - } - - public function testFromArrayThrowsOnNonArrayIcons(): void - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Invalid "icons" in Implementation data; expected an array.'); - - /* @phpstan-ignore argument.type */ - Implementation::fromArray(['name' => 'my-client', 'version' => '1.0.0', 'icons' => 'nope']); - } - - public function testJsonSerializeRoundTrip(): void - { - $implementation = Implementation::fromArray([ - 'name' => 'my-client', - 'version' => '0', - 'description' => 'A test client', - 'websiteUrl' => 'https://example.com', - ]); - - $this->assertSame([ - 'name' => 'my-client', - 'version' => '0', - 'description' => 'A test client', - 'websiteUrl' => 'https://example.com', - ], $implementation->jsonSerialize()); - } - - public function testJsonSerializeOmitsNullOptionalFields(): void - { - $implementation = new Implementation('my-client', '1.0.0'); - - $this->assertSame([ - 'name' => 'my-client', - 'version' => '1.0.0', - ], $implementation->jsonSerialize()); - } -} diff --git a/tests/Unit/Schema/JsonRpc/NotificationTest.php b/tests/Unit/Schema/JsonRpc/NotificationTest.php deleted file mode 100644 index 0d7d7c48..00000000 --- a/tests/Unit/Schema/JsonRpc/NotificationTest.php +++ /dev/null @@ -1,56 +0,0 @@ - '2.0', - 'method' => 'notifications/dummy', - 'params' => [ - '_meta' => ['key' => 'value'], - ], - ]); - - $expectedMeta = [ - 'jsonrpc' => '2.0', - 'method' => 'notifications/dummy', - 'params' => [ - '_meta' => ['key' => 'value'], - ], - ]; - - $this->assertSame($expectedMeta, $notification->jsonSerialize()); - } -} diff --git a/tests/Unit/Schema/JsonRpc/RequestTest.php b/tests/Unit/Schema/JsonRpc/RequestTest.php deleted file mode 100644 index f400a904..00000000 --- a/tests/Unit/Schema/JsonRpc/RequestTest.php +++ /dev/null @@ -1,58 +0,0 @@ - '2.0', - 'id' => '12345', - 'method' => 'foo/bar', - 'params' => [ - '_meta' => ['key' => 'value'], - ], - ]); - - $expectedMeta = [ - 'jsonrpc' => '2.0', - 'id' => '12345', - 'method' => 'foo/bar', - 'params' => [ - '_meta' => ['key' => 'value'], - ], - ]; - - $this->assertSame($expectedMeta, $notification->jsonSerialize()); - } -} diff --git a/tests/Unit/Schema/Request/CreateSamplingMessageRequestTest.php b/tests/Unit/Schema/Request/CreateSamplingMessageRequestTest.php deleted file mode 100644 index fb065e61..00000000 --- a/tests/Unit/Schema/Request/CreateSamplingMessageRequestTest.php +++ /dev/null @@ -1,194 +0,0 @@ -assertCount(3, $request->messages); - $this->assertSame(150, $request->maxTokens); - } - - public function testConstructorWithInvalidSetOfMessages(): void - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Messages must be instance of SamplingMessage.'); - - $messages = [ - new SamplingMessage(Role::User, new TextContent('My name is George.')), - new SamplingMessage(Role::Assistant, new TextContent('Hi George, nice to meet you!')), - new TextContent('What is my name?'), - ]; - - /* @phpstan-ignore argument.type */ - new CreateSamplingMessageRequest($messages, 150); - } - - public function testToolsAndToolChoiceRoundTrip(): void - { - $tool = new Tool('weather', null, ['type' => 'object', 'properties' => [], 'required' => null], 'Get weather', null); - $request = new CreateSamplingMessageRequest( - [new SamplingMessage(Role::User, new TextContent('Weather in Paris?'))], - 150, - tools: [$tool], - toolChoice: new ToolChoice(ToolChoiceMode::Required), - ); - - $payload = $request->withId(1)->jsonSerialize(); - $this->assertSame('weather', $payload['params']['tools'][0]->name); - $this->assertSame(ToolChoiceMode::Required, $payload['params']['toolChoice']->mode); - - $hydrated = CreateSamplingMessageRequest::fromArray(json_decode(json_encode($payload, \JSON_THROW_ON_ERROR), true, flags: \JSON_THROW_ON_ERROR)); - $this->assertSame('weather', $hydrated->tools[0]->name); - $this->assertSame(ToolChoiceMode::Required, $hydrated->toolChoice->mode); - } - - public function testValidToolFlowPasses(): void - { - $this->expectNotToPerformAssertions(); - - $this->requestFor([ - new SamplingMessage(Role::User, new TextContent('Weather in Paris and London?')), - new SamplingMessage(Role::Assistant, [ - new ToolUseContent('call-1', 'weather', ['city' => 'Paris']), - new ToolUseContent('call-2', 'weather', ['city' => 'London']), - ]), - new SamplingMessage(Role::User, [ - new ToolResultContent('call-1', [new TextContent('18 C')]), - new ToolResultContent('call-2', [new TextContent('15 C')]), - ]), - new SamplingMessage(Role::Assistant, new TextContent('Paris is warmer.')), - ])->validateToolFlow(); - } - - public function testToolResultsMixedWithOtherContentAreRejected(): void - { - $request = $this->requestFor([ - new SamplingMessage(Role::Assistant, new ToolUseContent('call-1', 'weather', [])), - new SamplingMessage(Role::User, [ - new ToolResultContent('call-1', [new TextContent('18 C')]), - new TextContent('and also...'), - ]), - ]); - - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Tool results mixed with other content.'); - - $request->validateToolFlow(); - } - - public function testToolUseInUserMessageIsRejected(): void - { - $request = $this->requestFor([ - new SamplingMessage(Role::User, new ToolUseContent('call-1', 'weather', [])), - ]); - - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('ToolUseContent is only valid in assistant sampling messages.'); - - $request->validateToolFlow(); - } - - public function testToolResultInAssistantMessageIsRejected(): void - { - $request = $this->requestFor([ - new SamplingMessage(Role::Assistant, new ToolResultContent('call-1', [new TextContent('18 C')])), - ]); - - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('ToolResultContent is only valid in user sampling messages.'); - - $request->validateToolFlow(); - } - - public function testUnansweredToolUseIsRejected(): void - { - $request = $this->requestFor([ - new SamplingMessage(Role::Assistant, [ - new ToolUseContent('call-1', 'weather', []), - new ToolUseContent('call-2', 'weather', []), - ]), - new SamplingMessage(Role::User, [new ToolResultContent('call-1', [new TextContent('18 C')])]), - ]); - - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Tool result missing in request.'); - - $request->validateToolFlow(); - } - - public function testTrailingToolUseIsRejected(): void - { - $request = $this->requestFor([ - new SamplingMessage(Role::Assistant, new ToolUseContent('call-1', 'weather', [])), - ]); - - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Tool result missing in request.'); - - $request->validateToolFlow(); - } - - public function testToolUseFollowedByPlainMessageIsRejected(): void - { - $request = $this->requestFor([ - new SamplingMessage(Role::Assistant, new ToolUseContent('call-1', 'weather', [])), - new SamplingMessage(Role::User, new TextContent('never mind')), - ]); - - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Tool result missing in request.'); - - $request->validateToolFlow(); - } - - public function testUnsolicitedToolResultIsRejected(): void - { - $request = $this->requestFor([ - new SamplingMessage(Role::User, new ToolResultContent('call-9', [new TextContent('18 C')])), - ]); - - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Tool result "call-9" does not answer a preceding tool use.'); - - $request->validateToolFlow(); - } - - /** - * @param SamplingMessage[] $messages - */ - private function requestFor(array $messages): CreateSamplingMessageRequest - { - return new CreateSamplingMessageRequest($messages, 150); - } -} diff --git a/tests/Unit/Schema/Request/ElicitRequestTest.php b/tests/Unit/Schema/Request/ElicitRequestTest.php deleted file mode 100644 index 5421e3d7..00000000 --- a/tests/Unit/Schema/Request/ElicitRequestTest.php +++ /dev/null @@ -1,72 +0,0 @@ - new StringSchemaDefinition('Name'), - ]); - - $request = new ElicitRequest('Please provide your name', $schema); - - $this->assertSame('Please provide your name', $request->message); - $this->assertSame($schema, $request->requestedSchema); - } - - public function testGetMethod(): void - { - $this->assertSame('elicitation/create', ElicitRequest::getMethod()); - } - - public function testJsonSerialization(): void - { - $schema = new ElicitationSchema( - [ - 'name' => new StringSchemaDefinition('Name'), - 'age' => new NumberSchemaDefinition('Age', integerOnly: true, minimum: 0), - ], - ['name'], - ); - - $request = new ElicitRequest('Please provide your details', $schema); - $request = $request->withId(1); - - $json = json_encode($request); - $this->assertIsString($json); - - $decoded = json_decode($json, true); - $this->assertIsArray($decoded); - - $this->assertSame('2.0', $decoded['jsonrpc']); - $this->assertSame('elicitation/create', $decoded['method']); - $this->assertArrayHasKey('params', $decoded); - - $params = $decoded['params']; - $this->assertSame('Please provide your details', $params['message']); - $this->assertArrayHasKey('requestedSchema', $params); - - $requestedSchema = $params['requestedSchema']; - $this->assertSame('object', $requestedSchema['type']); - $this->assertArrayHasKey('name', $requestedSchema['properties']); - $this->assertArrayHasKey('age', $requestedSchema['properties']); - $this->assertSame(['name'], $requestedSchema['required']); - } -} diff --git a/tests/Unit/Schema/ResourceDefinitionTest.php b/tests/Unit/Schema/ResourceDefinitionTest.php deleted file mode 100644 index ab39f41a..00000000 --- a/tests/Unit/Schema/ResourceDefinitionTest.php +++ /dev/null @@ -1,144 +0,0 @@ -assertInstanceOf(ResourceDefinition::class, $resource); - $this->assertSame($uri, $resource->uri); - } - - public function testConstructorInvalid(): void - { - $uri = '/list-books'; - - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Invalid resource URI: "/list-books" must be a valid URI with a scheme and optional path.'); - - $resource = new ResourceDefinition( - uri: $uri, - name: 'list-books', - ); - } - - #[DataProvider('provideValidUris')] - public function testConstructorAcceptsUris(string $uri): void - { - $resource = new ResourceDefinition( - uri: $uri, - name: 'test-resource', - ); - - $this->assertInstanceOf(ResourceDefinition::class, $resource); - $this->assertSame($uri, $resource->uri); - } - - public static function provideValidUris(): iterable - { - yield 'urn' => ['urn:isbn:0451450523']; - yield 'mailto' => ['mailto:user@example.com']; - yield 'data' => ['data:text/plain;base64,SGVsbG8=']; - yield 'custom scheme without slashes' => ['config:myapp/settings']; - yield 'custom scheme with slashes' => ['config://myapp/settings']; - } - - public function testFromArrayValid(): void - { - $resource = ResourceDefinition::fromArray([ - 'uri' => self::VALID_URI, - 'name' => 'list-books', - ]); - - $this->assertInstanceOf(ResourceDefinition::class, $resource); - $this->assertSame(self::VALID_URI, $resource->uri); - $this->assertSame('list-books', $resource->name); - $this->assertNull($resource->title); - $this->assertNull($resource->description); - $this->assertNull($resource->meta); - } - - public function testTitleFromArray(): void - { - $resource = ResourceDefinition::fromArray([ - 'uri' => self::VALID_URI, - 'name' => 'list-books', - 'title' => 'Book Listing', - ]); - - $this->assertSame('Book Listing', $resource->title); - } - - public function testTitleSerialization(): void - { - $resource = new ResourceDefinition( - uri: self::VALID_URI, - name: 'list-books', - title: 'Book Listing', - ); - - $data = $resource->jsonSerialize(); - $this->assertSame('Book Listing', $data['title']); - } - - public function testTitleOmittedWhenNull(): void - { - $resource = new ResourceDefinition( - uri: self::VALID_URI, - name: 'list-books', - ); - - $data = $resource->jsonSerialize(); - $this->assertArrayNotHasKey('title', $data); - } - - #[DataProvider('provideInvalidResources')] - public function testFromArrayInvalid(array $input, string $expectedExceptionMessage): void - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage($expectedExceptionMessage); - - ResourceDefinition::fromArray($input); - } - - public static function provideInvalidResources(): iterable - { - yield 'missing uri' => [[], 'Invalid or missing "uri" in ResourceDefinition data.']; - yield 'missing name' => [ - ['uri' => self::VALID_URI], - 'Invalid or missing "name" in ResourceDefinition data.', - ]; - yield 'meta' => [ - [ - 'uri' => self::VALID_URI, - 'name' => 'list-books', - '_meta' => 'foo', - ], - 'Invalid "_meta" in ResourceDefinition data.', - ]; - } -} diff --git a/tests/Unit/Schema/ResourceTemplateTest.php b/tests/Unit/Schema/ResourceTemplateTest.php deleted file mode 100644 index 3e2908dc..00000000 --- a/tests/Unit/Schema/ResourceTemplateTest.php +++ /dev/null @@ -1,142 +0,0 @@ -assertInstanceOf(ResourceTemplate::class, $resource); - $this->assertSame($uri, $resource->uriTemplate); - } - - public function testConstructorInvalid(): void - { - $uri = '/list-books'; - - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Invalid URI template : "/list-books" must be a valid URI template with at least one placeholder.'); - - $resource = new ResourceTemplate( - uriTemplate: $uri, - name: 'list-books', - ); - } - - #[DataProvider('provideValidTemplates')] - public function testConstructorAcceptsTemplates(string $uriTemplate): void - { - $resource = new ResourceTemplate( - uriTemplate: $uriTemplate, - name: 'test-template', - ); - - $this->assertInstanceOf(ResourceTemplate::class, $resource); - $this->assertSame($uriTemplate, $resource->uriTemplate); - } - - public static function provideValidTemplates(): iterable - { - yield 'custom scheme without slashes' => ['config:{key}']; - yield 'custom scheme with slashes' => ['config://{key}']; - yield 'urn-style template' => ['urn:resource:{id}']; - } - - public function testFromArrayValid(): void - { - $resource = ResourceTemplate::fromArray([ - 'uriTemplate' => self::VALID_URI, - 'name' => 'list-books', - ]); - - $this->assertInstanceOf(ResourceTemplate::class, $resource); - $this->assertSame(self::VALID_URI, $resource->uriTemplate); - $this->assertSame('list-books', $resource->name); - $this->assertNull($resource->title); - $this->assertNull($resource->description); - $this->assertNull($resource->meta); - } - - public function testTitleFromArray(): void - { - $resource = ResourceTemplate::fromArray([ - 'uriTemplate' => self::VALID_URI, - 'name' => 'list-books', - 'title' => 'Book Listing', - ]); - - $this->assertSame('Book Listing', $resource->title); - } - - public function testTitleSerialization(): void - { - $resource = new ResourceTemplate( - uriTemplate: self::VALID_URI, - name: 'list-books', - title: 'Book Listing', - ); - - $data = $resource->jsonSerialize(); - $this->assertSame('Book Listing', $data['title']); - } - - public function testTitleOmittedWhenNull(): void - { - $resource = new ResourceTemplate( - uriTemplate: self::VALID_URI, - name: 'list-books', - ); - - $data = $resource->jsonSerialize(); - $this->assertArrayNotHasKey('title', $data); - } - - #[DataProvider('provideInvalidResources')] - public function testFromArrayInvalid(array $input, string $expectedExceptionMessage): void - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage($expectedExceptionMessage); - - ResourceTemplate::fromArray($input); - } - - public static function provideInvalidResources(): iterable - { - yield 'missing uri' => [[], 'Invalid or missing "uriTemplate" in ResourceTemplate data.']; - yield 'missing name' => [ - ['uriTemplate' => self::VALID_URI], - 'Invalid or missing "name" in ResourceTemplate data.', - ]; - yield 'meta' => [ - [ - 'uriTemplate' => self::VALID_URI, - 'name' => 'list-books', - '_meta' => 'foo', - ], - 'Invalid "_meta" in ResourceTemplate data.', - ]; - } -} diff --git a/tests/Unit/Schema/Result/CallToolResultTest.php b/tests/Unit/Schema/Result/CallToolResultTest.php deleted file mode 100644 index f7c2b6ff..00000000 --- a/tests/Unit/Schema/Result/CallToolResultTest.php +++ /dev/null @@ -1,112 +0,0 @@ - [ - [ - 'type' => 'resource_link', - 'uri' => 'file:///project/src/main.rs', - 'name' => 'main.rs', - 'mimeType' => 'text/x-rust', - ], - ], - 'isError' => false, - ]); - - $this->assertCount(1, $result->content); - $this->assertInstanceOf(ResourceLink::class, $result->content[0]); - $this->assertSame('file:///project/src/main.rs', $result->content[0]->uri); - $this->assertSame('main.rs', $result->content[0]->name); - $this->assertSame('text/x-rust', $result->content[0]->mimeType); - } - - public function testFromArrayDeserializesMixedContentTypes(): void - { - $result = CallToolResult::fromArray([ - 'content' => [ - ['type' => 'text', 'text' => 'search results'], - ['type' => 'resource_link', 'uri' => 'file:///a.png', 'name' => 'a.png'], - ['type' => 'resource_link', 'uri' => 'file:///b.png', 'name' => 'b.png'], - ], - ]); - - $this->assertCount(3, $result->content); - $this->assertInstanceOf(TextContent::class, $result->content[0]); - $this->assertInstanceOf(ResourceLink::class, $result->content[1]); - $this->assertInstanceOf(ResourceLink::class, $result->content[2]); - } - - public function testFromArrayRejectsUnknownContentType(): void - { - $this->expectException(InvalidArgumentException::class); - - CallToolResult::fromArray([ - 'content' => [ - ['type' => 'not-a-real-type'], - ], - ]); - } - - public function testJsonSerializeIncludesResourceLinkContent(): void - { - $result = new CallToolResult([ - new ResourceLink('file:///project/src/main.rs', 'main.rs'), - ]); - - $data = $result->jsonSerialize(); - - $this->assertSame([ - 'type' => 'resource_link', - 'uri' => 'file:///project/src/main.rs', - 'name' => 'main.rs', - ], $data['content'][0]->jsonSerialize()); - } - - public function testRoundTripWithResourceLinkAlongsideOtherContentTypes(): void - { - $original = new CallToolResult([ - new TextContent('25 results found'), - new ResourceLink('file:///a.png', 'a.png', mimeType: 'image/png'), - new ImageContent(base64_encode('binary'), 'image/png'), - new AudioContent(base64_encode('binary'), 'audio/mpeg'), - EmbeddedResource::fromText('file:///readme.txt', 'hello'), - ]); - - $decoded = json_decode(json_encode($original), true); - $rehydrated = CallToolResult::fromArray($decoded); - - $this->assertCount(5, $rehydrated->content); - $this->assertInstanceOf(TextContent::class, $rehydrated->content[0]); - $this->assertInstanceOf(ResourceLink::class, $rehydrated->content[1]); - $this->assertInstanceOf(ImageContent::class, $rehydrated->content[2]); - $this->assertInstanceOf(AudioContent::class, $rehydrated->content[3]); - $this->assertInstanceOf(EmbeddedResource::class, $rehydrated->content[4]); - - $this->assertSame('file:///a.png', $rehydrated->content[1]->uri); - $this->assertSame('a.png', $rehydrated->content[1]->name); - $this->assertSame('image/png', $rehydrated->content[1]->mimeType); - } -} diff --git a/tests/Unit/Schema/Result/CreateSamplingMessageResultTest.php b/tests/Unit/Schema/Result/CreateSamplingMessageResultTest.php deleted file mode 100644 index 41961058..00000000 --- a/tests/Unit/Schema/Result/CreateSamplingMessageResultTest.php +++ /dev/null @@ -1,128 +0,0 @@ - 'assistant', - 'content' => [ - ['type' => 'text', 'text' => 'Checking weather.'], - ['type' => 'tool_use', 'id' => 'call-1', 'name' => 'weather', 'input' => ['city' => 'Paris']], - ], - 'model' => 'test-model', - 'stopReason' => 'toolUse', - '_meta' => ['traceId' => 'trace-1'], - ]); - - $this->assertInstanceOf(TextContent::class, $result->content[0]); - $this->assertInstanceOf(ToolUseContent::class, $result->content[1]); - $this->assertSame('toolUse', $result->stopReason); - $this->assertSame('toolUse', $result->jsonSerialize()['stopReason']); - $this->assertSame(['traceId' => 'trace-1'], $result->jsonSerialize()['_meta']); - } - - public function testProviderSpecificStopReasonIsPreserved(): void - { - $result = CreateSamplingMessageResult::fromArray([ - 'role' => 'assistant', - 'content' => ['type' => 'text', 'text' => 'Done'], - 'model' => 'test-model', - 'stopReason' => 'provider-specific', - ]); - - $this->assertSame('provider-specific', $result->stopReason); - $this->assertSame('provider-specific', $result->jsonSerialize()['stopReason']); - } - - public function testKnownStopReasonStaysAString(): void - { - $result = CreateSamplingMessageResult::fromArray([ - 'role' => 'assistant', - 'content' => ['type' => 'text', 'text' => 'Done'], - 'model' => 'test-model', - 'stopReason' => 'endTurn', - ]); - - $this->assertSame('endTurn', $result->stopReason); - } - - public function testSingleContentBlockKeepsItsShape(): void - { - $result = CreateSamplingMessageResult::fromArray([ - 'role' => 'assistant', - 'content' => ['type' => 'text', 'text' => 'Done'], - 'model' => 'test-model', - ]); - - $this->assertInstanceOf(TextContent::class, $result->content); - $this->assertCount(1, $result->getContentBlocks()); - $this->assertSame('{"type":"text","text":"Done"}', json_encode($result->jsonSerialize()['content'])); - } - - public function testFilteredContentStillSerializesAsAnArray(): void - { - $blocks = [new TextContent('thinking'), new ToolUseContent('call-1', 'weather', [])]; - - // array_filter() preserves keys, so this list starts at index 1. - $toolUses = array_filter($blocks, static fn ($block): bool => $block instanceof ToolUseContent); - $result = new CreateSamplingMessageResult(Role::Assistant, $toolUses, 'test-model'); - - $this->assertSame( - '[{"type":"tool_use","id":"call-1","name":"weather","input":{}}]', - json_encode($result->jsonSerialize()['content']), - ); - } - - public function testNonAssistantRoleIsRejected(): void - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('CreateSamplingMessageResult role must be "assistant".'); - - CreateSamplingMessageResult::fromArray([ - 'role' => 'user', - 'content' => ['type' => 'text', 'text' => 'Done'], - 'model' => 'test-model', - ]); - } - - public function testEmptyContentIsRejected(): void - { - $this->expectException(InvalidArgumentException::class); - - CreateSamplingMessageResult::fromArray([ - 'role' => 'assistant', - 'content' => [], - 'model' => 'test-model', - ]); - } - - public function testToolResultContentIsRejected(): void - { - $this->expectException(InvalidArgumentException::class); - - CreateSamplingMessageResult::fromArray([ - 'role' => 'assistant', - 'content' => ['type' => 'tool_result', 'toolUseId' => 'call-1', 'content' => []], - 'model' => 'test-model', - ]); - } -} diff --git a/tests/Unit/Schema/Result/ElicitResultTest.php b/tests/Unit/Schema/Result/ElicitResultTest.php deleted file mode 100644 index 62091a50..00000000 --- a/tests/Unit/Schema/Result/ElicitResultTest.php +++ /dev/null @@ -1,162 +0,0 @@ - 'John', 'age' => 30]; - $result = new ElicitResult(ElicitAction::Accept, $content); - - $this->assertSame(ElicitAction::Accept, $result->action); - $this->assertSame($content, $result->content); - } - - public function testConstructorWithDecline(): void - { - $result = new ElicitResult(ElicitAction::Decline); - - $this->assertSame(ElicitAction::Decline, $result->action); - $this->assertNull($result->content); - } - - public function testConstructorWithCancel(): void - { - $result = new ElicitResult(ElicitAction::Cancel); - - $this->assertSame(ElicitAction::Cancel, $result->action); - $this->assertNull($result->content); - } - - public function testFromArrayWithAccept(): void - { - $result = ElicitResult::fromArray([ - 'action' => 'accept', - 'content' => ['name' => 'John', 'email' => 'john@example.com'], - ]); - - $this->assertSame(ElicitAction::Accept, $result->action); - $this->assertSame(['name' => 'John', 'email' => 'john@example.com'], $result->content); - } - - public function testFromArrayWithDecline(): void - { - $result = ElicitResult::fromArray([ - 'action' => 'decline', - ]); - - $this->assertSame(ElicitAction::Decline, $result->action); - $this->assertNull($result->content); - } - - public function testFromArrayWithCancel(): void - { - $result = ElicitResult::fromArray([ - 'action' => 'cancel', - ]); - - $this->assertSame(ElicitAction::Cancel, $result->action); - $this->assertNull($result->content); - } - - public function testFromArrayWithMissingAction(): void - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Missing or invalid "action"'); - - /* @phpstan-ignore argument.type */ - ElicitResult::fromArray([]); - } - - public function testFromArrayWithInvalidAction(): void - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Invalid "action" value "invalid"'); - - ElicitResult::fromArray(['action' => 'invalid']); - } - - public function testFromArrayWithAcceptActionRequiresContent(): void - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Content must be provided when action is "accept"'); - - ElicitResult::fromArray(['action' => 'accept']); - } - - public function testIsAccepted(): void - { - $acceptResult = new ElicitResult(ElicitAction::Accept, ['name' => 'John']); - $declineResult = new ElicitResult(ElicitAction::Decline); - $cancelResult = new ElicitResult(ElicitAction::Cancel); - - $this->assertTrue($acceptResult->isAccepted()); - $this->assertFalse($declineResult->isAccepted()); - $this->assertFalse($cancelResult->isAccepted()); - } - - public function testIsDeclined(): void - { - $acceptResult = new ElicitResult(ElicitAction::Accept, ['name' => 'John']); - $declineResult = new ElicitResult(ElicitAction::Decline); - $cancelResult = new ElicitResult(ElicitAction::Cancel); - - $this->assertFalse($acceptResult->isDeclined()); - $this->assertTrue($declineResult->isDeclined()); - $this->assertFalse($cancelResult->isDeclined()); - } - - public function testIsCancelled(): void - { - $acceptResult = new ElicitResult(ElicitAction::Accept, ['name' => 'John']); - $declineResult = new ElicitResult(ElicitAction::Decline); - $cancelResult = new ElicitResult(ElicitAction::Cancel); - - $this->assertFalse($acceptResult->isCancelled()); - $this->assertFalse($declineResult->isCancelled()); - $this->assertTrue($cancelResult->isCancelled()); - } - - public function testJsonSerializeWithAcceptAndContent(): void - { - $result = new ElicitResult(ElicitAction::Accept, ['name' => 'John', 'age' => 30]); - - $this->assertSame([ - 'action' => 'accept', - 'content' => ['name' => 'John', 'age' => 30], - ], $result->jsonSerialize()); - } - - public function testJsonSerializeWithDecline(): void - { - $result = new ElicitResult(ElicitAction::Decline); - - $this->assertSame([ - 'action' => 'decline', - ], $result->jsonSerialize()); - } - - public function testJsonSerializeWithCancel(): void - { - $result = new ElicitResult(ElicitAction::Cancel); - - $this->assertSame([ - 'action' => 'cancel', - ], $result->jsonSerialize()); - } -} diff --git a/tests/Unit/Schema/Result/ListRootsResultTest.php b/tests/Unit/Schema/Result/ListRootsResultTest.php deleted file mode 100644 index 46729fbf..00000000 --- a/tests/Unit/Schema/Result/ListRootsResultTest.php +++ /dev/null @@ -1,110 +0,0 @@ -assertSame($roots, $result->roots); - $this->assertNull($result->meta); - } - - public function testFromArray(): void - { - $result = ListRootsResult::fromArray([ - 'roots' => [ - ['uri' => 'file:///home/user/project', 'name' => 'project'], - ['uri' => 'file:///tmp'], - ], - ]); - - $this->assertCount(2, $result->roots); - $this->assertSame('file:///home/user/project', $result->roots[0]->uri); - $this->assertSame('project', $result->roots[0]->name); - $this->assertSame('file:///tmp', $result->roots[1]->uri); - $this->assertNull($result->roots[1]->name); - $this->assertNull($result->meta); - } - - public function testFromArrayWithMeta(): void - { - $result = ListRootsResult::fromArray([ - 'roots' => [['uri' => 'file:///tmp']], - '_meta' => ['requestId' => 'abc'], - ]); - - $this->assertSame(['requestId' => 'abc'], $result->meta); - } - - public function testFromArrayWithMissingRoots(): void - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Missing or invalid "roots"'); - - /* @phpstan-ignore argument.type */ - ListRootsResult::fromArray([]); - } - - public function testFromArrayWithNonArrayRoot(): void - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Invalid root in ListRootsResult data, expected an array.'); - - /* @phpstan-ignore argument.type */ - ListRootsResult::fromArray([ - 'roots' => ['file:///tmp'], - ]); - } - - public function testFromArrayRejectsNonFileUri(): void - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('must start with "file://"'); - - ListRootsResult::fromArray([ - 'roots' => [['uri' => 'https://example.com']], - ]); - } - - public function testFromArrayRoundTrip(): void - { - $data = [ - 'roots' => [ - ['uri' => 'file:///home/user/project', 'name' => 'project'], - ['uri' => 'file:///tmp'], - ], - '_meta' => ['foo' => 'bar'], - ]; - - $result = ListRootsResult::fromArray($data); - - $this->assertSame($data, json_decode(json_encode($result), true)); - } - - public function testJsonSerializeWithoutMeta(): void - { - $result = new ListRootsResult([new Root('file:///tmp')]); - - $this->assertSame([ - 'roots' => [['uri' => 'file:///tmp']], - ], json_decode(json_encode($result), true)); - } -} diff --git a/tests/Unit/Schema/ServerCapabilitiesTest.php b/tests/Unit/Schema/ServerCapabilitiesTest.php deleted file mode 100644 index 9d1562c1..00000000 --- a/tests/Unit/Schema/ServerCapabilitiesTest.php +++ /dev/null @@ -1,406 +0,0 @@ -assertTrue($capabilities->tools); - $this->assertFalse($capabilities->toolsListChanged); - $this->assertTrue($capabilities->resources); - $this->assertFalse($capabilities->resourcesSubscribe); - $this->assertFalse($capabilities->resourcesListChanged); - $this->assertTrue($capabilities->prompts); - $this->assertFalse($capabilities->promptsListChanged); - $this->assertFalse($capabilities->logging); - $this->assertFalse($capabilities->completions); - $this->assertNull($capabilities->experimental); - } - - public function testConstructorWithAllParameters(): void - { - $experimental = ['feature1' => true, 'feature2' => 'enabled']; - - $capabilities = new ServerCapabilities( - tools: false, - toolsListChanged: true, - resources: false, - resourcesSubscribe: true, - resourcesListChanged: true, - prompts: false, - promptsListChanged: true, - logging: true, - completions: true, - experimental: $experimental - ); - - $this->assertFalse($capabilities->tools); - $this->assertTrue($capabilities->toolsListChanged); - $this->assertFalse($capabilities->resources); - $this->assertTrue($capabilities->resourcesSubscribe); - $this->assertTrue($capabilities->resourcesListChanged); - $this->assertFalse($capabilities->prompts); - $this->assertTrue($capabilities->promptsListChanged); - $this->assertTrue($capabilities->logging); - $this->assertTrue($capabilities->completions); - $this->assertEquals($experimental, $capabilities->experimental); - } - - public function testConstructorWithNullValues(): void - { - $capabilities = new ServerCapabilities( - tools: null, - toolsListChanged: null, - resources: null, - resourcesSubscribe: null, - resourcesListChanged: null, - prompts: null, - promptsListChanged: null, - logging: null, - completions: null, - experimental: null - ); - - $this->assertNull($capabilities->tools); - $this->assertNull($capabilities->toolsListChanged); - $this->assertNull($capabilities->resources); - $this->assertNull($capabilities->resourcesSubscribe); - $this->assertNull($capabilities->resourcesListChanged); - $this->assertNull($capabilities->prompts); - $this->assertNull($capabilities->promptsListChanged); - $this->assertNull($capabilities->logging); - $this->assertNull($capabilities->completions); - $this->assertNull($capabilities->experimental); - } - - public function testFromArrayWithEmptyArray(): void - { - $capabilities = ServerCapabilities::fromArray([]); - - $this->assertFalse($capabilities->logging); - $this->assertFalse($capabilities->completions); - $this->assertFalse($capabilities->tools); - $this->assertFalse($capabilities->prompts); - $this->assertFalse($capabilities->resources); - $this->assertNull($capabilities->toolsListChanged); - $this->assertNull($capabilities->promptsListChanged); - $this->assertNull($capabilities->resourcesSubscribe); - $this->assertNull($capabilities->resourcesListChanged); - $this->assertNull($capabilities->experimental); - } - - public function testFromArrayWithBasicCapabilities(): void - { - $data = [ - 'tools' => new \stdClass(), - 'resources' => new \stdClass(), - 'prompts' => new \stdClass(), - 'logging' => new \stdClass(), - 'completions' => new \stdClass(), - ]; - - $capabilities = ServerCapabilities::fromArray($data); - - $this->assertTrue($capabilities->tools); - $this->assertTrue($capabilities->resources); - $this->assertTrue($capabilities->prompts); - $this->assertTrue($capabilities->logging); - $this->assertTrue($capabilities->completions); - $this->assertNull($capabilities->toolsListChanged); - $this->assertNull($capabilities->promptsListChanged); - $this->assertNull($capabilities->resourcesSubscribe); - $this->assertNull($capabilities->resourcesListChanged); - } - - public function testFromArrayWithPromptsArrayListChanged(): void - { - $data = [ - 'prompts' => ['listChanged' => true], - ]; - - $capabilities = ServerCapabilities::fromArray($data); - - $this->assertTrue($capabilities->prompts); - $this->assertTrue($capabilities->promptsListChanged); - } - - public function testFromArrayWithPromptsObjectListChanged(): void - { - $prompts = new \stdClass(); - $prompts->listChanged = true; - - $data = [ - 'prompts' => $prompts, - ]; - - $capabilities = ServerCapabilities::fromArray($data); - - $this->assertTrue($capabilities->prompts); - $this->assertTrue($capabilities->promptsListChanged); - } - - public function testFromArrayWithResourcesArraySubscribeAndListChanged(): void - { - $data = [ - 'resources' => [ - 'subscribe' => true, - 'listChanged' => false, - ], - ]; - - $capabilities = ServerCapabilities::fromArray($data); - - $this->assertTrue($capabilities->resources); - $this->assertTrue($capabilities->resourcesSubscribe); - $this->assertFalse($capabilities->resourcesListChanged); - } - - public function testFromArrayWithResourcesObjectSubscribeAndListChanged(): void - { - $resources = new \stdClass(); - $resources->subscribe = false; - $resources->listChanged = true; - - $data = [ - 'resources' => $resources, - ]; - - $capabilities = ServerCapabilities::fromArray($data); - - $this->assertTrue($capabilities->resources); - $this->assertFalse($capabilities->resourcesSubscribe); - $this->assertTrue($capabilities->resourcesListChanged); - } - - public function testFromArrayWithToolsArrayListChanged(): void - { - $data = [ - 'tools' => ['listChanged' => false], - ]; - - $capabilities = ServerCapabilities::fromArray($data); - - $this->assertTrue($capabilities->tools); - $this->assertFalse($capabilities->toolsListChanged); - } - - public function testFromArrayWithToolsObjectListChanged(): void - { - $tools = new \stdClass(); - $tools->listChanged = true; - - $data = [ - 'tools' => $tools, - ]; - - $capabilities = ServerCapabilities::fromArray($data); - - $this->assertTrue($capabilities->tools); - $this->assertTrue($capabilities->toolsListChanged); - } - - public function testFromArrayWithExperimental(): void - { - $experimental = ['feature1' => true, 'feature2' => 'test']; - $data = [ - 'experimental' => $experimental, - ]; - - $capabilities = ServerCapabilities::fromArray($data); - - $this->assertEquals($experimental, $capabilities->experimental); - } - - public function testFromArrayWithComplexData(): void - { - $data = [ - 'tools' => ['listChanged' => true], - 'resources' => [ - 'subscribe' => true, - 'listChanged' => false, - ], - 'prompts' => ['listChanged' => true], - 'logging' => new \stdClass(), - 'completions' => new \stdClass(), - 'experimental' => ['customFeature' => 'enabled'], - ]; - - $capabilities = ServerCapabilities::fromArray($data); - - $this->assertTrue($capabilities->tools); - $this->assertTrue($capabilities->toolsListChanged); - $this->assertTrue($capabilities->resources); - $this->assertTrue($capabilities->resourcesSubscribe); - $this->assertFalse($capabilities->resourcesListChanged); - $this->assertTrue($capabilities->prompts); - $this->assertTrue($capabilities->promptsListChanged); - $this->assertTrue($capabilities->logging); - $this->assertTrue($capabilities->completions); - $this->assertEquals(['customFeature' => 'enabled'], $capabilities->experimental); - } - - public function testJsonSerializeWithDefaults(): void - { - $capabilities = new ServerCapabilities(); - $json = $capabilities->jsonSerialize(); - - $expected = [ - 'tools' => new \stdClass(), - 'resources' => new \stdClass(), - 'prompts' => new \stdClass(), - ]; - - $this->assertEquals($expected, $json); - } - - public function testJsonSerializeWithAllFeaturesEnabled(): void - { - $experimental = ['feature1' => true]; - $capabilities = new ServerCapabilities( - tools: true, - toolsListChanged: true, - resources: true, - resourcesSubscribe: true, - resourcesListChanged: true, - prompts: true, - promptsListChanged: true, - logging: true, - completions: true, - experimental: $experimental - ); - - $json = $capabilities->jsonSerialize(); - - $this->assertArrayHasKey('logging', $json); - $this->assertEquals(new \stdClass(), $json['logging']); - - $this->assertArrayHasKey('completions', $json); - $this->assertEquals(new \stdClass(), $json['completions']); - - $this->assertArrayHasKey('prompts', $json); - $this->assertTrue($json['prompts']->listChanged); - - $this->assertArrayHasKey('resources', $json); - $this->assertTrue($json['resources']->subscribe); - $this->assertTrue($json['resources']->listChanged); - - $this->assertArrayHasKey('tools', $json); - $this->assertTrue($json['tools']->listChanged); - - $this->assertArrayHasKey('experimental', $json); - $this->assertEquals((object) $experimental, $json['experimental']); - } - - public function testJsonSerializeWithFalseValues(): void - { - $capabilities = new ServerCapabilities( - tools: false, - resources: false, - prompts: false, - logging: false, - completions: false - ); - - $json = $capabilities->jsonSerialize(); - - $this->assertEquals([], $json); - } - - public function testJsonSerializeWithMixedValues(): void - { - $capabilities = new ServerCapabilities( - tools: true, - toolsListChanged: false, - resources: false, - resourcesSubscribe: true, - resourcesListChanged: true, - prompts: true, - promptsListChanged: false, - logging: false, - completions: true - ); - - $json = $capabilities->jsonSerialize(); - - $expected = [ - 'completions' => new \stdClass(), - 'prompts' => new \stdClass(), - 'resources' => (object) [ - 'subscribe' => true, - 'listChanged' => true, - ], - 'tools' => new \stdClass(), - ]; - - $this->assertEquals($expected, $json); - } - - public function testJsonSerializeWithOnlyListChangedFlags(): void - { - $capabilities = new ServerCapabilities( - tools: false, - toolsListChanged: true, - resources: false, - resourcesListChanged: true, - prompts: false, - promptsListChanged: true - ); - - $json = $capabilities->jsonSerialize(); - - $expected = [ - 'prompts' => (object) ['listChanged' => true], - 'resources' => (object) ['listChanged' => true], - 'tools' => (object) ['listChanged' => true], - ]; - - $this->assertEquals($expected, $json); - } - - public function testJsonSerializeWithNullExperimental(): void - { - $capabilities = new ServerCapabilities( - tools: true, - experimental: null - ); - - $json = $capabilities->jsonSerialize(); - - $this->assertArrayNotHasKey('experimental', $json); - $this->assertArrayHasKey('tools', $json); - } - - public function testFromArrayHandlesEdgeCasesGracefully(): void - { - $data = [ - 'prompts' => [], - 'resources' => [], - 'tools' => [], - ]; - - $capabilities = ServerCapabilities::fromArray($data); - - $this->assertTrue($capabilities->prompts); - $this->assertNull($capabilities->promptsListChanged); - $this->assertTrue($capabilities->resources); - $this->assertNull($capabilities->resourcesSubscribe); - $this->assertNull($capabilities->resourcesListChanged); - $this->assertTrue($capabilities->tools); - $this->assertNull($capabilities->toolsListChanged); - } -} diff --git a/tests/Unit/Schema/ToolChoiceTest.php b/tests/Unit/Schema/ToolChoiceTest.php deleted file mode 100644 index 66ba54da..00000000 --- a/tests/Unit/Schema/ToolChoiceTest.php +++ /dev/null @@ -1,63 +0,0 @@ - - */ - public static function provideModes(): iterable - { - yield 'auto' => [ToolChoiceMode::Auto]; - yield 'required' => [ToolChoiceMode::Required]; - yield 'none' => [ToolChoiceMode::None]; - } - - #[DataProvider('provideModes')] - public function testRoundTrip(ToolChoiceMode $mode): void - { - $choice = new ToolChoice($mode); - - $this->assertSame(\sprintf('{"mode":"%s"}', $mode->value), json_encode($choice)); - $this->assertSame($mode, ToolChoice::fromArray(json_decode(json_encode($choice), true))->mode); - } - - public function testModeDefaultsToAuto(): void - { - $this->assertSame(ToolChoiceMode::Auto, (new ToolChoice())->mode); - $this->assertSame(ToolChoiceMode::Auto, ToolChoice::fromArray([])->mode); - } - - public function testUnknownModeIsRejected(): void - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Invalid tool choice mode "any".'); - - ToolChoice::fromArray(['mode' => 'any']); - } - - public function testNonStringModeIsRejected(): void - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Invalid "mode" in ToolChoice data.'); - - /* @phpstan-ignore argument.type */ - ToolChoice::fromArray(['mode' => 1]); - } -} diff --git a/tests/Unit/Schema/ToolTest.php b/tests/Unit/Schema/ToolTest.php deleted file mode 100644 index dc71189d..00000000 --- a/tests/Unit/Schema/ToolTest.php +++ /dev/null @@ -1,292 +0,0 @@ -, required: string[]|null} - */ - private static function validInputSchema(): array - { - return [ - 'type' => 'object', - 'properties' => ['q' => ['type' => 'string']], - 'required' => null, - ]; - } - - private static function makeTool(?string $title, ?string $description = null): Tool - { - return new Tool( - name: 'x', - inputSchema: self::validInputSchema(), - description: $description, - annotations: null, - title: $title, - ); - } - - /** - * @return iterable}> - */ - public static function serializationKeyOrderProvider(): iterable - { - yield 'with title' => ['Friendly Title', ['name', 'title', 'inputSchema']]; - yield 'without title' => [null, ['name', 'inputSchema']]; - } - - /** - * @param list $expectedKeys - */ - #[DataProvider('serializationKeyOrderProvider')] - public function testSerializationPlacesTitleBetweenNameAndInputSchema(?string $title, array $expectedKeys): void - { - $serialized = self::makeTool($title)->jsonSerialize(); - - $this->assertSame($expectedKeys, array_keys($serialized)); - if (null !== $title) { - $this->assertSame($title, $serialized['title']); - } else { - $this->assertArrayNotHasKey('title', $serialized); - } - } - - /** - * @return iterable, ?string}> - */ - public static function fromArrayTitleProvider(): iterable - { - yield 'title present' => [['title' => 'Friendly Title'], 'Friendly Title']; - yield 'title missing' => [[], null]; - } - - /** - * @param array $extra - */ - #[DataProvider('fromArrayTitleProvider')] - public function testFromArrayReadsTitle(array $extra, ?string $expectedTitle): void - { - $tool = Tool::fromArray(['name' => 'x', 'inputSchema' => self::validInputSchema()] + $extra); - - $this->assertSame($expectedTitle, $tool->title); - } - - public function testRoundTripPreservesTitle(): void - { - $original = self::makeTool('Friendly Title', 'desc'); - - /** @var array{name: string, title?: string, inputSchema: array{type: 'object', properties: array, required: string[]|null}, description?: string|null} $serialized */ - $serialized = $original->jsonSerialize(); - $restored = Tool::fromArray($serialized); - - $this->assertSame('Friendly Title', $restored->title); - $this->assertSame($original->name, $restored->name); - $this->assertSame($original->description, $restored->description); - } - - public function testConstructorNormalizesEmptyInputSchemaPropertiesToObject(): void - { - $tool = new Tool( - name: 'no_params', - title: null, - inputSchema: ['type' => 'object', 'properties' => [], 'required' => null], - description: null, - annotations: null, - ); - - $this->assertInstanceOf(\stdClass::class, $tool->inputSchema['properties']); - $this->assertSame('{"name":"no_params","inputSchema":{"type":"object","properties":{},"required":null}}', json_encode($tool)); - } - - public function testConstructorNormalizesEmptyPropertiesAfterJsonDecodeRoundTrip(): void - { - /** @var array{type: 'object', properties: array, required: null} $schema */ - $schema = json_decode('{"type":"object","properties":{},"required":null}', true); - $this->assertSame([], $schema['properties']); - - $tool = new Tool('t', null, $schema, null, null); - - $this->assertInstanceOf(\stdClass::class, $tool->inputSchema['properties']); - $this->assertStringContainsString('"properties":{}', (string) json_encode($tool)); - } - - public function testFromArrayNormalizesNestedEmptyPropertiesRecursively(): void - { - $tool = Tool::fromArray([ - 'name' => 't', - 'inputSchema' => [ - 'type' => 'object', - 'properties' => [ - 'filter' => ['type' => 'object', 'properties' => []], - ], - 'required' => null, - ], - ]); - - $this->assertInstanceOf(\stdClass::class, $tool->inputSchema['properties']['filter']['properties']); - $this->assertStringContainsString('"properties":{}', (string) json_encode($tool->inputSchema['properties']['filter'])); - } - - public function testConstructorNormalizesEmptyOutputSchemaProperties(): void - { - $tool = new Tool( - name: 't', - title: null, - inputSchema: ['type' => 'object', 'properties' => ['q' => ['type' => 'string']], 'required' => null], - description: null, - annotations: null, - outputSchema: ['type' => 'object', 'properties' => []], - ); - - $this->assertInstanceOf(\stdClass::class, $tool->outputSchema['properties']); - $this->assertStringContainsString('"outputSchema":{"type":"object","properties":{}}', (string) json_encode($tool)); - } - - public function testConstructorNormalizesEmptyPropertiesInsideArrayItems(): void - { - $tool = new Tool( - name: 't', - title: null, - inputSchema: [ - 'type' => 'object', - 'properties' => [ - 'rows' => [ - 'type' => 'array', - 'items' => ['type' => 'object', 'properties' => []], - ], - ], - 'required' => null, - ], - description: null, - annotations: null, - ); - - $this->assertInstanceOf(\stdClass::class, $tool->inputSchema['properties']['rows']['items']['properties']); - } - - /** - * Each case is a schema that is already valid JSON: decoding it collapses every `{}` - * to `[]`, and normalization has to restore it verbatim. - * - * @return iterable - */ - public static function emptySubSchemaProvider(): iterable - { - yield 'top-level properties' => ['{"type":"object","properties":{}}']; - yield 'nested properties' => ['{"type":"object","properties":{"filter":{"type":"object","properties":{}}}}']; - yield 'property schema' => ['{"type":"object","properties":{"anything":{}}}']; - yield 'items schema' => ['{"type":"object","properties":{"tags":{"type":"array","items":{}}}}']; - yield 'tuple items schema' => ['{"type":"object","properties":{"pair":{"type":"array","items":[{"type":"object","properties":{}},{}]}}}']; - yield 'additionalItems schema' => ['{"type":"object","properties":{"tags":{"type":"array","additionalItems":{}}}}']; - yield 'additionalProperties schema' => ['{"type":"object","properties":{"map":{"type":"object","additionalProperties":{}}}}']; - yield 'propertyNames schema' => ['{"type":"object","properties":{"map":{"type":"object","propertyNames":{}}}}']; - yield 'contains schema' => ['{"type":"object","properties":{"tags":{"type":"array","contains":{}}}}']; - yield 'unevaluatedItems schema' => ['{"type":"object","properties":{"tags":{"type":"array","unevaluatedItems":{}}}}']; - yield 'unevaluatedProperties schema' => ['{"type":"object","properties":{"map":{"type":"object","unevaluatedProperties":{}}}}']; - yield 'not schema' => ['{"type":"object","properties":{"a":{"not":{}}}}']; - yield 'if/then/else schemas' => ['{"type":"object","properties":{"a":{"if":{},"then":{"type":"object","properties":{}},"else":{}}}}']; - yield '$defs entry' => ['{"type":"object","properties":{"a":{"$ref":"#/$defs/E"}},"$defs":{"E":{"type":"object","properties":{}}}}']; - yield 'definitions entry' => ['{"type":"object","properties":{"a":{"$ref":"#/definitions/E"}},"definitions":{"E":{}}}']; - yield 'patternProperties entry' => ['{"type":"object","properties":{"map":{"type":"object","patternProperties":{"^x":{}}}}}']; - yield 'dependentSchemas entry' => ['{"type":"object","properties":{"a":{"type":"string"}},"dependentSchemas":{"a":{}}}']; - yield 'combinator branches' => ['{"type":"object","properties":{"a":{"anyOf":[{},{"type":"object","properties":{}}],"oneOf":[{}],"allOf":[{}]}}}']; - yield 'prefixItems entry' => ['{"type":"object","properties":{"a":{"type":"array","prefixItems":[{},{"type":"object","properties":{}}]}}}']; - } - - #[DataProvider('emptySubSchemaProvider')] - public function testConstructorNormalizesEmptySubSchemas(string $schemaJson): void - { - /** @var array{type: 'object', properties: array, required: string[]|null} $schema */ - $schema = json_decode($schemaJson, true, 512, \JSON_THROW_ON_ERROR); - - $tool = new Tool(name: 't', title: null, inputSchema: $schema, description: null, annotations: null); - - $this->assertSame($schemaJson, json_encode($tool->inputSchema, \JSON_UNESCAPED_SLASHES)); - } - - #[DataProvider('emptySubSchemaProvider')] - public function testConstructorNormalizesEmptySubSchemasInOutputSchema(string $schemaJson): void - { - /** @var array{type: 'object', properties?: array} $schema */ - $schema = json_decode($schemaJson, true, 512, \JSON_THROW_ON_ERROR); - - $tool = new Tool( - name: 't', - title: null, - inputSchema: self::validInputSchema(), - description: null, - annotations: null, - outputSchema: $schema, - ); - - $this->assertSame($schemaJson, json_encode($tool->outputSchema, \JSON_UNESCAPED_SLASHES)); - } - - /** - * @return iterable - */ - public static function preservedEmptyArrayProvider(): iterable - { - yield 'empty combinator list' => ['{"type":"object","properties":{},"allOf":[]}']; - yield 'empty prefixItems list' => ['{"type":"object","properties":{},"prefixItems":[]}']; - yield 'empty required list' => ['{"type":"object","properties":{},"required":[]}']; - yield 'empty enum list' => ['{"type":"object","properties":{"a":{"enum":[]}}}']; - yield 'empty dependentRequired list' => ['{"type":"object","properties":{"a":{}},"dependentRequired":{"a":[]}}']; - } - - /** - * Keywords that hold JSON arrays — not sub-schemas — must keep encoding as `[]`. - */ - #[DataProvider('preservedEmptyArrayProvider')] - public function testConstructorLeavesNonSchemaEmptyArraysAlone(string $schemaJson): void - { - /** @var array{type: 'object', properties: array, required: string[]|null} $schema */ - $schema = json_decode($schemaJson, true, 512, \JSON_THROW_ON_ERROR); - - $tool = new Tool(name: 't', title: null, inputSchema: $schema, description: null, annotations: null); - - $this->assertSame($schemaJson, json_encode($tool->inputSchema, \JSON_UNESCAPED_SLASHES)); - } - - /** - * Regression test for #151: `SchemaGenerator` emits `items: {}` for untyped arrays, - * but a client decoding that payload gets `items: []` back — re-serializing it used to - * hand strict clients the very schema #151 fixed. - */ - public function testFromArrayRoundTripPreservesEmptyItemsSchema(): void - { - $tool = new Tool( - name: 't', - title: null, - inputSchema: [ - 'type' => 'object', - 'properties' => ['tags' => ['type' => 'array', 'items' => new \stdClass()]], - 'required' => null, - ], - description: null, - annotations: null, - ); - - $wire = (string) json_encode($tool); - $this->assertStringContainsString('"items":{}', $wire); - - /** @var array{name: string, inputSchema: array{type: 'object', properties: array, required: string[]|null}} $decoded */ - $decoded = json_decode($wire, true, 512, \JSON_THROW_ON_ERROR); - - $this->assertSame($wire, json_encode(Tool::fromArray($decoded))); - } -} diff --git a/tests/Unit/Server/BuilderTest.php b/tests/Unit/Server/BuilderTest.php deleted file mode 100644 index 255a142e..00000000 --- a/tests/Unit/Server/BuilderTest.php +++ /dev/null @@ -1,269 +0,0 @@ -createStub(ReferenceHandlerInterface::class); - - $builder = Server::builder(); - $result = $builder->setReferenceHandler($referenceHandler); - - $this->assertSame($builder, $result); - } - - #[TestDox('build() succeeds with a custom ReferenceHandler')] - public function testBuildWithCustomReferenceHandler(): void - { - $referenceHandler = $this->createStub(ReferenceHandlerInterface::class); - - $server = Server::builder() - ->setServerInfo('test', '1.0.0') - ->setReferenceHandler($referenceHandler) - ->build(); - - $this->assertInstanceOf(Server::class, $server); - } - - #[TestDox('build() succeeds without a custom ReferenceHandler (uses default)')] - public function testBuildWithoutCustomReferenceHandler(): void - { - $server = Server::builder() - ->setServerInfo('test', '1.0.0') - ->build(); - - $this->assertInstanceOf(Server::class, $server); - } - - #[TestDox('Custom ReferenceHandler is used when calling a tool')] - public function testCustomReferenceHandlerIsUsedForToolCalls(): void - { - $referenceHandler = $this->createMock(ReferenceHandlerInterface::class); - $referenceHandler->expects($this->once()) - ->method('handle') - ->willReturnCallback(static function (ElementReference $reference, array $arguments): string { - return 'intercepted'; - }); - - $server = Server::builder() - ->setServerInfo('test', '1.0.0') - ->setReferenceHandler($referenceHandler) - ->addTool(static fn (): string => 'original', name: 'test_tool', description: 'A test tool') - ->build(); - - $result = $this->callTool($server, 'test_tool'); - - $this->assertSame('intercepted', $result); - } - - #[TestDox('A pre-built instance handler with constructor dependencies is registered and invoked on that instance')] - public function testPreBuiltInstanceHandlerIsInvokedOnTheGivenInstance(): void - { - // The handler's constructor requires an argument the container-less - // `new $className()` fallback can never satisfy. If the tool call - // succeeds, it can only be because the pre-built instance — carrying - // its injected dependency — was the one invoked. - $handler = new GreetingService('World'); - - $server = Server::builder() - ->setServerInfo('test', '1.0.0') - ->addTool(handler: [$handler, 'greet'], name: 'greet', description: 'Greets using the injected name') - ->build(); - - $result = $this->callTool($server, 'greet'); - - $this->assertSame('Hello, World', $result); - } - - #[TestDox('enableExtension() registers an extension and announces its capability payload')] - public function testEnableExtensionRegistersExtension(): void - { - $server = Server::builder() - ->setServerInfo('test', '1.0.0') - ->enableExtension(new McpApps()) - ->build(); - - $capabilities = $this->extractServerCapabilities($server); - - $this->assertNotNull($capabilities->extensions); - $this->assertArrayHasKey(McpApps::EXTENSION_ID, $capabilities->extensions); - $this->assertSame(['mimeTypes' => [McpApps::MIME_TYPE]], $capabilities->extensions[McpApps::EXTENSION_ID]); - } - - #[TestDox('enableExtension() throws when the same extension is enabled twice')] - public function testEnableExtensionRejectsDuplicate(): void - { - $this->expectException(LogicException::class); - $this->expectExceptionMessage(McpApps::EXTENSION_ID); - - Server::builder()->enableExtension(new McpApps(), new McpApps()); - } - - #[TestDox('enableExtension() extensions are merged into capabilities set via setCapabilities()')] - public function testEnableExtensionMergesIntoCustomCapabilities(): void - { - $server = Server::builder() - ->setServerInfo('test', '1.0.0') - ->setCapabilities(new ServerCapabilities(tools: true)) - ->enableExtension(new McpApps()) - ->build(); - - $capabilities = $this->extractServerCapabilities($server); - - $this->assertNotNull($capabilities->extensions); - $this->assertArrayHasKey(McpApps::EXTENSION_ID, $capabilities->extensions); - } - - #[TestDox('build() advertises tools capability for a pre-populated registry set via setRegistry()')] - public function testBuildAdvertisesToolsForPreloadedCustomRegistry(): void - { - $registry = new Registry(); - $registry->registerTool( - new Tool(name: 'test_tool', title: null, inputSchema: ['type' => 'object', 'properties' => [], 'required' => null], description: 'A test tool', annotations: null), - static fn (): string => 'result', - ); - - $server = Server::builder() - ->setServerInfo('test', '1.0.0') - ->setRegistry($registry) - ->build(); - - $capabilities = $this->extractServerCapabilities($server); - - $this->assertTrue($capabilities->tools); - } - - #[TestDox('setLazyLoading() returns the builder for fluent chaining')] - public function testSetLazyLoadingReturnsSelf(): void - { - $builder = Server::builder(); - - $this->assertSame($builder, $builder->setLazyLoading(false)); - } - - #[TestDox('Lazy loading (default) advertises tools from configured sources without running loaders')] - public function testLazyLoadingAdvertisesFromConfiguredSourcesWithoutLoading(): void - { - $loader = $this->createMock(LoaderInterface::class); - $loader->expects($this->never())->method('load'); - - $server = Server::builder() - ->setServerInfo('test', '1.0.0') - ->addLoader($loader) - ->build(); - - $capabilities = $this->extractServerCapabilities($server); - - // A custom loader is opaque, so its presence advertises tools even though it never ran. - $this->assertTrue($capabilities->tools); - } - - #[TestDox('Eager loading runs the loaders at build time and advertises from the loaded registry')] - public function testEagerLoadingAdvertisesFromLoadedRegistry(): void - { - $loader = $this->createMock(LoaderInterface::class); - $loader->expects($this->once())->method('load'); - - $server = Server::builder() - ->setServerInfo('test', '1.0.0') - ->setLazyLoading(false) - ->addLoader($loader) - ->build(); - - $capabilities = $this->extractServerCapabilities($server); - - // The loader ran but registered nothing, so the loaded registry advertises no tools. - $this->assertFalse($capabilities->tools); - } - - private function extractServerCapabilities(Server $server): ServerCapabilities - { - $protocol = (new \ReflectionClass($server))->getProperty('protocol')->getValue($server); - $requestHandlers = (new \ReflectionClass($protocol))->getProperty('requestHandlers')->getValue($protocol); - - foreach ($requestHandlers as $handler) { - if ($handler instanceof InitializeHandler) { - return $handler->configuration->capabilities; - } - } - - $this->fail('InitializeHandler not found in request handlers'); - } - - private function callTool(Server $server, string $toolName): mixed - { - $protocol = (new \ReflectionClass($server))->getProperty('protocol')->getValue($server); - $requestHandlers = (new \ReflectionClass($protocol))->getProperty('requestHandlers')->getValue($protocol); - - foreach ($requestHandlers as $handler) { - if ($handler instanceof CallToolHandler) { - $request = CallToolRequest::fromArray([ - 'jsonrpc' => '2.0', - 'method' => 'tools/call', - 'id' => 'test-1', - 'params' => ['name' => $toolName, 'arguments' => []], - ]); - $session = $this->createStub(SessionInterface::class); - - $response = $handler->handle($request, $session); - - if ($response instanceof Response) { - $content = $response->result->content[0] ?? null; - - return $content instanceof TextContent ? $content->text : null; - } - - $this->fail('Expected Response, got '.$response::class); - } - } - - $this->fail('CallToolHandler not found in request handlers'); - } -} - -/** - * A handler whose constructor dependency cannot be satisfied by the - * container-less `new $className()` fallback, so it must be registered as a - * pre-built instance. - */ -final class GreetingService -{ - public function __construct(private string $name) - { - } - - public function greet(): string - { - return 'Hello, '.$this->name; - } -} diff --git a/tests/Unit/Server/ClientGatewayTest.php b/tests/Unit/Server/ClientGatewayTest.php deleted file mode 100644 index e6c2cee2..00000000 --- a/tests/Unit/Server/ClientGatewayTest.php +++ /dev/null @@ -1,129 +0,0 @@ -createMock(SessionInterface::class); - $session->method('get')->with('client_capabilities', [])->willReturn(['roots' => []]); - - $gateway = new ClientGateway($session); - - $this->assertTrue($gateway->supportsRoots()); - } - - public function testSupportsRootsReturnsFalseWhenNotAdvertised(): void - { - $session = $this->createMock(SessionInterface::class); - $session->method('get')->with('client_capabilities', [])->willReturn(['sampling' => []]); - - $gateway = new ClientGateway($session); - - $this->assertFalse($gateway->supportsRoots()); - } - - public function testSupportsSamplingReturnsTrueWhenAdvertised(): void - { - $session = $this->createMock(SessionInterface::class); - $session->method('get')->with('client_capabilities', [])->willReturn(['sampling' => []]); - - $gateway = new ClientGateway($session); - - $this->assertTrue($gateway->supportsSampling()); - } - - public function testSupportsSamplingReturnsFalseWhenNotAdvertised(): void - { - $session = $this->createMock(SessionInterface::class); - $session->method('get')->with('client_capabilities', [])->willReturn(['roots' => []]); - - $gateway = new ClientGateway($session); - - $this->assertFalse($gateway->supportsSampling()); - } - - public function testListRootsReturnsRootsFromClient(): void - { - $session = $this->createMock(SessionInterface::class); - $session->method('getId')->willReturn(Uuid::v4()); - - $gateway = new ClientGateway($session); - - $response = $this->response([ - 'roots' => [ - ['uri' => 'file:///home/user/project', 'name' => 'project'], - ], - ]); - - $result = $this->runInFiber(static fn (): ListRootsResult => $gateway->listRoots(), $response); - - $this->assertInstanceOf(ListRootsResult::class, $result); - $this->assertCount(1, $result->roots); - $this->assertSame('file:///home/user/project', $result->roots[0]->uri); - } - - public function testListRootsThrowsClientExceptionOnError(): void - { - $session = $this->createMock(SessionInterface::class); - $session->method('getId')->willReturn(Uuid::v4()); - - $gateway = new ClientGateway($session); - - $error = Error::forInternalError('nope', '1'); - - $this->expectException(ClientException::class); - - $this->runInFiber(static fn (): ListRootsResult => $gateway->listRoots(), $error); - } - - /** - * Runs the gateway call inside a Fiber, asserts it suspends with a roots/list - * request, then resumes it with the given client response. - * - * @param Response>|Error $response - */ - private function runInFiber(\Closure $call, Response|Error $response): mixed - { - $fiber = new \Fiber($call); - $suspend = $fiber->start(); - - $this->assertIsArray($suspend); - $this->assertSame('request', $suspend['type']); - $this->assertInstanceOf(ListRootsRequest::class, $suspend['request']); - - $fiber->resume($response); - - return $fiber->getReturn(); - } - - /** - * @param array $result - * - * @return Response> - */ - private function response(array $result): Response - { - return new Response('1', $result); - } -} diff --git a/tests/Unit/Server/Handler/Request/CallToolHandlerTest.php b/tests/Unit/Server/Handler/Request/CallToolHandlerTest.php deleted file mode 100644 index 87a696be..00000000 --- a/tests/Unit/Server/Handler/Request/CallToolHandlerTest.php +++ /dev/null @@ -1,697 +0,0 @@ -registry = $this->createMock(RegistryInterface::class); - $this->referenceHandler = $this->createMock(ReferenceHandlerInterface::class); - $this->logger = $this->createMock(LoggerInterface::class); - $this->session = $this->createMock(SessionInterface::class); - - $this->handler = new CallToolHandler( - $this->registry, - $this->referenceHandler, - $this->logger, - ); - } - - public function testSupportsCallToolRequest(): void - { - $request = $this->createCallToolRequest('test_tool', ['param' => 'value']); - - $this->assertTrue($this->handler->supports($request)); - } - - public function testHandleSuccessfulToolCall(): void - { - $request = $this->createCallToolRequest('greet_user', ['name' => 'John']); - $toolReference = $this->createToolReference('greet_user', static function () { - return 'Hello, John!'; - }); - $expectedResult = new CallToolResult([new TextContent('Hello, John!')]); - - $this->registry - ->expects($this->once()) - ->method('getTool') - ->with('greet_user') - ->willReturn($toolReference); - - $this->referenceHandler - ->expects($this->once()) - ->method('handle') - ->with($toolReference, ['name' => 'John', '_session' => $this->session, '_request' => $request]) - ->willReturn('Hello, John!'); - - $toolReference - ->expects($this->once()) - ->method('formatResult') - ->with('Hello, John!') - ->willReturn([new TextContent('Hello, John!')]); - - // Logger may be called for debugging, so we don't assert never() - - $response = $this->handler->handle($request, $this->session); - - $this->assertInstanceOf(Response::class, $response); - $this->assertEquals($request->getId(), $response->id); - $this->assertEquals($expectedResult, $response->result); - } - - public function testHandleToolCallWithEmptyArguments(): void - { - $request = $this->createCallToolRequest('simple_tool', []); - $toolReference = $this->createToolReference('greet_user', static function () { - return 'Hello, John!'; - }); - $expectedResult = new CallToolResult([new TextContent('Simple result')]); - - $this->registry - ->expects($this->once()) - ->method('getTool') - ->with('simple_tool') - ->willReturn($toolReference); - - $this->referenceHandler - ->expects($this->once()) - ->method('handle') - ->with($toolReference, ['_session' => $this->session, '_request' => $request]) - ->willReturn('Simple result'); - - $toolReference - ->expects($this->once()) - ->method('formatResult') - ->with('Simple result') - ->willReturn([new TextContent('Simple result')]); - - $response = $this->handler->handle($request, $this->session); - - $this->assertInstanceOf(Response::class, $response); - $this->assertEquals($expectedResult, $response->result); - } - - public function testHandleToolCallWithComplexArguments(): void - { - $arguments = [ - 'string_param' => 'value', - 'int_param' => 42, - 'bool_param' => true, - 'array_param' => ['nested' => 'data'], - 'null_param' => null, - ]; - $request = $this->createCallToolRequest('complex_tool', $arguments); - $toolReference = $this->createToolReference('greet_user', static function () { - return 'Hello, John!'; - }); - $expectedResult = new CallToolResult([new TextContent('Complex result')]); - - $this->registry - ->expects($this->once()) - ->method('getTool') - ->with('complex_tool') - ->willReturn($toolReference); - - $this->referenceHandler - ->expects($this->once()) - ->method('handle') - ->with($toolReference, array_merge($arguments, ['_session' => $this->session, '_request' => $request])) - ->willReturn('Complex result'); - - $toolReference - ->expects($this->once()) - ->method('formatResult') - ->with('Complex result') - ->willReturn([new TextContent('Complex result')]); - - $response = $this->handler->handle($request, $this->session); - - $this->assertInstanceOf(Response::class, $response); - $this->assertEquals($expectedResult, $response->result); - } - - public function testHandleToolNotFoundExceptionReturnsError(): void - { - $request = $this->createCallToolRequest('nonexistent_tool', ['param' => 'value']); - $exception = new ToolNotFoundException('nonexistent_tool'); - - $this->registry - ->expects($this->once()) - ->method('getTool') - ->with('nonexistent_tool') - ->willThrowException($exception); - - $this->logger - ->expects($this->once()) - ->method('error') - ->with('Tool not found', ['name' => 'nonexistent_tool', 'exception' => $exception]); - - $response = $this->handler->handle($request, $this->session); - - $this->assertInstanceOf(Error::class, $response); - $this->assertEquals($request->getId(), $response->id); - $this->assertEquals(Error::METHOD_NOT_FOUND, $response->code); - } - - public function testHandleToolCallExceptionReturnsResponseWithErrorResult(): void - { - $request = $this->createCallToolRequest('failing_tool', ['param' => 'value']); - $exception = new ToolCallException('Tool execution failed'); - - $toolReference = $this->createToolReference('greet_user', static function () { - return 'Hello, John!'; - }); - $this->registry - ->expects($this->once()) - ->method('getTool') - ->with('failing_tool') - ->willReturn($toolReference); - - $this->referenceHandler - ->expects($this->once()) - ->method('handle') - ->with($toolReference, ['param' => 'value', '_session' => $this->session, '_request' => $request]) - ->willThrowException($exception); - - $this->logger - ->expects($this->once()) - ->method('error') - ->with( - 'Error while executing tool "failing_tool": "Tool execution failed".', - [ - 'tool' => 'failing_tool', - 'arguments' => ['param' => 'value', '_session' => $this->session, '_request' => $request], - 'exception' => $exception, - ], - ); - - $response = $this->handler->handle($request, $this->session); - - $this->assertInstanceOf(Response::class, $response); - $this->assertEquals($request->getId(), $response->id); - - $result = $response->result; - $this->assertInstanceOf(CallToolResult::class, $result); - $this->assertTrue($result->isError); - $this->assertCount(1, $result->content); - $this->assertInstanceOf(TextContent::class, $result->content[0]); - $this->assertEquals('Tool execution failed', $result->content[0]->text); - } - - public function testHandleWithNullResult(): void - { - $request = $this->createCallToolRequest('null_tool', []); - $expectedResult = new CallToolResult([]); - - $toolReference = $this->createToolReference('greet_user', static function () { - return 'Hello, John!'; - }); - $this->registry - ->expects($this->once()) - ->method('getTool') - ->with('null_tool') - ->willReturn($toolReference); - - $this->referenceHandler - ->expects($this->once()) - ->method('handle') - ->with($toolReference, ['_session' => $this->session, '_request' => $request]) - ->willReturn(null); - - $toolReference - ->expects($this->once()) - ->method('formatResult') - ->with(null) - ->willReturn([]); - - $response = $this->handler->handle($request, $this->session); - - $this->assertInstanceOf(Response::class, $response); - $this->assertEquals($expectedResult, $response->result); - } - - public function testConstructorWithDefaultLogger(): void - { - $handler = new CallToolHandler($this->registry, $this->referenceHandler); - - $this->assertInstanceOf(CallToolHandler::class, $handler); - } - - public function testHandleLogsErrorWithCorrectParameters(): void - { - $request = $this->createCallToolRequest('test_tool', ['key1' => 'value1', 'key2' => 42]); - $exception = new ToolCallException('Custom error message'); - - $toolReference = $this->createToolReference('greet_user', static function () { - return 'Hello, John!'; - }); - $this->registry - ->expects($this->once()) - ->method('getTool') - ->with('test_tool') - ->willReturn($toolReference); - - $this->referenceHandler - ->expects($this->once()) - ->method('handle') - ->with($toolReference, ['key1' => 'value1', 'key2' => 42, '_session' => $this->session, '_request' => $request]) - ->willThrowException($exception); - - $this->logger - ->expects($this->once()) - ->method('error') - ->with( - 'Error while executing tool "test_tool": "Custom error message".', - [ - 'tool' => 'test_tool', - 'arguments' => ['key1' => 'value1', 'key2' => 42, '_session' => $this->session, '_request' => $request], - 'exception' => $exception, - ], - ); - - $response = $this->handler->handle($request, $this->session); - - // ToolCallException should now return Response with CallToolResult having isError=true - $this->assertInstanceOf(Response::class, $response); - $this->assertEquals($request->getId(), $response->id); - - $result = $response->result; - $this->assertInstanceOf(CallToolResult::class, $result); - $this->assertTrue($result->isError); - $this->assertCount(1, $result->content); - $this->assertInstanceOf(TextContent::class, $result->content[0]); - $this->assertEquals('Custom error message', $result->content[0]->text); - } - - public function testHandleGenericExceptionReturnsError(): void - { - $request = $this->createCallToolRequest('failing_tool', ['param' => 'value']); - $exception = new \RuntimeException('Internal database connection failed'); - - $toolReference = $this->createToolReference('greet_user', static function () { - return 'Hello, John!'; - }); - $this->registry - ->expects($this->once()) - ->method('getTool') - ->with('failing_tool') - ->willReturn($toolReference); - - $this->referenceHandler - ->expects($this->once()) - ->method('handle') - ->with($toolReference, ['param' => 'value', '_session' => $this->session, '_request' => $request]) - ->willThrowException($exception); - - $response = $this->handler->handle($request, $this->session); - - // Generic exceptions should return Error, not Response - $this->assertInstanceOf(Error::class, $response); - $this->assertEquals($request->getId(), $response->id); - $this->assertEquals(Error::INTERNAL_ERROR, $response->code); - $this->assertEquals('Error while executing tool', $response->message); - } - - public function testHandleWithSpecialCharactersInToolName(): void - { - $request = $this->createCallToolRequest('tool-with_special.chars', []); - $expectedResult = new CallToolResult([new TextContent('Special tool result')]); - - $toolReference = $this->createToolReference('greet_user', static function () { - return 'Hello, John!'; - }); - - $this->registry - ->expects($this->once()) - ->method('getTool') - ->with('tool-with_special.chars') - ->willReturn($toolReference); - - $this->referenceHandler - ->expects($this->once()) - ->method('handle') - ->with($toolReference, ['_session' => $this->session, '_request' => $request]) - ->willReturn('Special tool result'); - - $toolReference - ->expects($this->once()) - ->method('formatResult') - ->with('Special tool result') - ->willReturn([new TextContent('Special tool result')]); - - $response = $this->handler->handle($request, $this->session); - - $this->assertInstanceOf(Response::class, $response); - $this->assertEquals($expectedResult, $response->result); - } - - public function testHandleWithSpecialCharactersInArguments(): void - { - $arguments = [ - 'special_chars' => 'äöü ñ 中文 🚀', - 'unicode' => '\\u{1F600}', - 'quotes' => 'text with "quotes" and \'single quotes\'', - ]; - $request = $this->createCallToolRequest('unicode_tool', $arguments); - $expectedResult = new CallToolResult([new TextContent('Unicode handled')]); - - $toolReference = $this->createToolReference('greet_user', static function () { - return 'Hello, John!'; - }); - $this->registry - ->expects($this->once()) - ->method('getTool') - ->with('unicode_tool') - ->willReturn($toolReference); - - $this->referenceHandler - ->expects($this->once()) - ->method('handle') - ->with($toolReference, array_merge($arguments, ['_session' => $this->session, '_request' => $request])) - ->willReturn('Unicode handled'); - - $toolReference - ->expects($this->once()) - ->method('formatResult') - ->with('Unicode handled') - ->willReturn([new TextContent('Unicode handled')]); - - $response = $this->handler->handle($request, $this->session); - - $this->assertInstanceOf(Response::class, $response); - $this->assertEquals($expectedResult, $response->result); - } - - public function testHandleReturnsStructuredContentResult(): void - { - $request = $this->createCallToolRequest('structured_tool', ['query' => 'php']); - $toolReference = $this->createToolReference('greet_user', static function () { - return 'Hello, John!'; - }); - $structuredResult = new CallToolResult([new TextContent('Rendered results')], false, ['result' => 'Rendered results']); - - $this->registry - ->expects($this->once()) - ->method('getTool') - ->with('structured_tool') - ->willReturn($toolReference); - - $this->referenceHandler - ->expects($this->once()) - ->method('handle') - ->with($toolReference, ['query' => 'php', '_session' => $this->session, '_request' => $request]) - ->willReturn($structuredResult); - - $toolReference - ->expects($this->never()) - ->method('formatResult'); - - $response = $this->handler->handle($request, $this->session); - - $this->assertInstanceOf(Response::class, $response); - $this->assertSame($structuredResult, $response->result); - $this->assertEquals(['result' => 'Rendered results'], $response->result->jsonSerialize()['structuredContent'] ?? []); - } - - public function testHandleReturnsCallToolResult(): void - { - $request = $this->createCallToolRequest('result_tool', ['query' => 'php']); - $toolReference = $this->createToolReference('greet_user', static function () { - return 'Hello, John!'; - }); - $callToolResult = new CallToolResult([new TextContent('Error result')], true); - - $this->registry - ->expects($this->once()) - ->method('getTool') - ->with('result_tool') - ->willReturn($toolReference); - - $this->referenceHandler - ->expects($this->once()) - ->method('handle') - ->with($toolReference, ['query' => 'php', '_session' => $this->session, '_request' => $request]) - ->willReturn($callToolResult); - - $toolReference - ->expects($this->never()) - ->method('formatResult'); - - $response = $this->handler->handle($request, $this->session); - - $this->assertInstanceOf(Response::class, $response); - $this->assertSame($callToolResult, $response->result); - $this->assertArrayNotHasKey('structuredContent', $response->result->jsonSerialize()); - } - - /** - * @dataProvider provideStructuredContentRevisions - */ - public function testStructuredContentFollowsTheNegotiatedRevision(?string $negotiated, ?array $expected): void - { - $listResult = [['id' => 1], ['id' => 2]]; - $request = $this->createCallToolRequest('list_things', []); - $toolReference = $this->createToolReference('list_things', static fn () => $listResult); - - $this->session - ->method('get') - ->with('protocol_version') - ->willReturn($negotiated); - - $this->registry - ->method('getTool') - ->willReturn($toolReference); - - $this->referenceHandler - ->method('handle') - ->willReturn($listResult); - - $toolReference - ->method('formatResult') - ->willReturn([new TextContent('[{"id":1},{"id":2}]')]); - - $response = $this->handler->handle($request, $this->session); - - $this->assertInstanceOf(Response::class, $response); - $this->assertSame($expected, $response->result->structuredContent); - } - - /** - * How a revision is resolved is {@see \Mcp\Server\RequestContext}'s business - * and covered there; this only pins that the handler applies it. - * - * @return iterable}> - */ - public static function provideStructuredContentRevisions(): iterable - { - // A list is only emittable from 2026-07-28 (SEP-2106) on. Without a - // negotiated revision the handler assumes the stricter handshake rule. - yield 'no negotiated revision' => [null, null]; - yield '2025-11-25' => ['2025-11-25', null]; - yield '2026-07-28' => ['2026-07-28', [['id' => 1], ['id' => 2]]]; - } - - /** - * @dataProvider provideSelfBuiltResults - */ - public function testSelfBuiltResultIsSentUnchangedAndOnlyWarnedAbout( - ?string $negotiated, - ?array $structuredContent, - int $expectedWarnings, - ): void { - $request = $this->createCallToolRequest('build_result', []); - $toolReference = $this->createToolReference('build_result', static fn () => null); - $callToolResult = new CallToolResult([new TextContent('Built by hand')], false, $structuredContent); - - $this->session - ->method('get') - ->with('protocol_version') - ->willReturn($negotiated); - - $this->registry - ->method('getTool') - ->willReturn($toolReference); - - $this->referenceHandler - ->method('handle') - ->willReturn($callToolResult); - - $toolReference - ->expects($this->never()) - ->method('formatResult'); - - $this->logger - ->expects($this->exactly($expectedWarnings)) - ->method('warning'); - - $response = $this->handler->handle($request, $this->session); - - // Warned about, never rewritten: building the result is an explicit opt-out. - $this->assertInstanceOf(Response::class, $response); - $this->assertSame($callToolResult, $response->result); - $this->assertSame($structuredContent, $response->result->structuredContent); - } - - /** - * @return iterable, int}> - */ - public static function provideSelfBuiltResults(): iterable - { - yield 'list before SEP-2106' => ['2025-11-25', [['id' => 1]], 1]; - yield 'list without a negotiated revision' => [null, [['id' => 1]], 1]; - yield 'list from SEP-2106 on' => ['2026-07-28', [['id' => 1]], 0]; - yield 'object before SEP-2106' => ['2025-11-25', ['items' => [['id' => 1]]], 0]; - yield 'none at all' => ['2025-11-25', null, 0]; - // Dropped by `CallToolResult::jsonSerialize()` anyway, so nothing to warn about. - yield 'empty' => ['2025-11-25', [], 0]; - } - - public function testDeclaredOutputSchemaWithoutStructuredContentIsLogged(): void - { - $listResult = [['id' => 1]]; - $request = $this->createCallToolRequest('list_things', []); - $toolReference = $this->createToolReference('list_things', static fn () => $listResult, [ - 'type' => 'object', - 'properties' => ['items' => ['type' => 'array']], - ]); - - $this->registry - ->method('getTool') - ->willReturn($toolReference); - - $this->referenceHandler - ->method('handle') - ->willReturn($listResult); - - $toolReference - ->method('formatResult') - ->willReturn([new TextContent('[{"id":1}]')]); - - $this->logger - ->expects($this->once()) - ->method('warning') - ->with( - $this->stringContains('outputSchema'), - $this->callback(static fn (array $context): bool => 'list_things' === $context['name'] && 'array' === $context['result_type']), - ); - - $response = $this->handler->handle($request, $this->session); - - $this->assertInstanceOf(Response::class, $response); - $this->assertNull($response->result->structuredContent); - } - - public function testValidationError(): void - { - $schema = [ - 'type' => 'object', - 'properties' => [ - 'favorite_number' => [ - 'type' => 'number', - 'description' => 'Your favorite number', - ], - ], - 'required' => [ - 'favorite_number', - ], - ]; - - $request = $this->createCallToolRequest('result_tool', ['query' => 'php']); - $toolReference = $this->getMockBuilder(ToolReference::class) - ->setConstructorArgs([new Tool('simple_tool', null, $schema, null, null), static function () {}]) - ->getMock(); - - $this->registry - ->expects($this->once()) - ->method('getTool') - ->with('result_tool') - ->willReturn($toolReference); - - $this->referenceHandler - ->expects($this->never()) - ->method('handle'); - - $response = $this->handler->handle($request, $this->session); - - $this->assertInstanceOf(Error::class, $response); - $this->assertEquals($request->getId(), $response->id); - $this->assertEquals(Error::INVALID_PARAMS, $response->code); - } - - /** - * @param array $arguments - */ - private function createCallToolRequest(string $name, array $arguments): CallToolRequest - { - return CallToolRequest::fromArray([ - 'jsonrpc' => '2.0', - 'method' => CallToolRequest::getMethod(), - 'id' => 'test-request-'.uniqid(), - 'params' => [ - 'name' => $name, - 'arguments' => $arguments, - ], - ]); - } - - private function createToolReference( - string $name, - callable $handler, - ?array $outputSchema = null, - array $methodsToMock = ['formatResult'], - ): ToolReference&MockObject { - $schema = [ - 'type' => 'object', - 'properties' => [ - 'example' => [ - 'type' => 'string', - 'description' => 'This is just a dummy', - ], - ], - 'required' => [], - ]; - $tool = new Tool($name, null, $schema, null, null, null, null, $outputSchema); - - $builder = $this->getMockBuilder(ToolReference::class) - ->setConstructorArgs([$tool, $handler]); - - if (!empty($methodsToMock)) { - $builder->onlyMethods($methodsToMock); - } - - return $builder->getMock(); - } -} diff --git a/tests/Unit/Server/Handler/Request/CompletionCompleteHandlerTest.php b/tests/Unit/Server/Handler/Request/CompletionCompleteHandlerTest.php deleted file mode 100644 index 63eefba3..00000000 --- a/tests/Unit/Server/Handler/Request/CompletionCompleteHandlerTest.php +++ /dev/null @@ -1,97 +0,0 @@ -registry = $this->createMock(RegistryInterface::class); - $this->session = $this->createMock(SessionInterface::class); - - $this->handler = new CompletionCompleteHandler($this->registry); - } - - public function testReturnsEmptyCompletionForResourceWithoutTemplate(): void - { - $uri = 'file://static/readme.txt'; - $request = $this->createCompletionRequest($uri, ['name' => 'arg', 'value' => 'a']); - - $this->registry - ->expects($this->once()) - ->method('getResource') - ->with($uri) - ->willReturn($this->createMock(ResourceReference::class)); - - $response = $this->handler->handle($request, $this->session); - - $this->assertInstanceOf(Response::class, $response); - $this->assertEquals($request->getId(), $response->id); - $this->assertEquals(new CompletionCompleteResult([]), $response->result); - } - - public function testReturnsCompletionsForResourceTemplate(): void - { - $uri = 'file://users/alice'; - $request = $this->createCompletionRequest($uri, ['name' => 'id', 'value' => 'al']); - - $templateReference = new ResourceTemplateReference( - new ResourceTemplate('file://users/{id}', 'user'), - static fn () => null, - ['id' => new CompletionProviderFixture()], - ); - - $this->registry - ->expects($this->once()) - ->method('getResource') - ->with($uri) - ->willReturn($templateReference); - - $response = $this->handler->handle($request, $this->session); - - $this->assertInstanceOf(Response::class, $response); - $this->assertEquals(new CompletionCompleteResult(['alpha'], 1, false), $response->result); - } - - /** - * @param array{ name: string, value: string } $argument - */ - private function createCompletionRequest(string $uri, array $argument): CompletionCompleteRequest - { - return CompletionCompleteRequest::fromArray([ - 'jsonrpc' => '2.0', - 'method' => CompletionCompleteRequest::getMethod(), - 'id' => 'test-completion-'.uniqid(), - 'params' => [ - 'ref' => ['type' => 'ref/resource', 'uri' => $uri], - 'argument' => $argument, - ], - ]); - } -} diff --git a/tests/Unit/Server/Handler/Request/GetPromptHandlerTest.php b/tests/Unit/Server/Handler/Request/GetPromptHandlerTest.php deleted file mode 100644 index 204f9280..00000000 --- a/tests/Unit/Server/Handler/Request/GetPromptHandlerTest.php +++ /dev/null @@ -1,451 +0,0 @@ -referenceProvider = $this->createMock(RegistryInterface::class); - $this->referenceHandler = $this->createMock(ReferenceHandlerInterface::class); - $this->session = $this->createMock(SessionInterface::class); - $this->logger = $this->createMock(LoggerInterface::class); - - $this->handler = new GetPromptHandler($this->referenceProvider, $this->referenceHandler, $this->logger); - } - - public function testSupportsGetPromptRequest(): void - { - $request = $this->createGetPromptRequest('test_prompt'); - - $this->assertTrue($this->handler->supports($request)); - } - - public function testHandleSuccessfulPromptGet(): void - { - $request = $this->createGetPromptRequest('greeting_prompt'); - $expectedMessages = [ - new PromptMessage(Role::User, new TextContent('Hello, how can I help you?')), - ]; - $expectedResult = new GetPromptResult($expectedMessages); - - $promptReference = $this->createMock(PromptReference::class); - - $this->referenceProvider - ->expects($this->once()) - ->method('getPrompt') - ->with('greeting_prompt') - ->willReturn($promptReference); - - $this->referenceHandler - ->expects($this->once()) - ->method('handle') - ->with($promptReference, ['_session' => $this->session, '_request' => $request]) - ->willReturn($expectedMessages); - - $promptReference - ->expects($this->once()) - ->method('formatResult') - ->with($expectedMessages) - ->willReturn($expectedMessages); - - $response = $this->handler->handle($request, $this->session); - - $this->assertInstanceOf(Response::class, $response); - $this->assertEquals($request->getId(), $response->id); - $this->assertEquals($expectedResult, $response->result); - } - - public function testHandlePromptGetWithArguments(): void - { - $arguments = [ - 'name' => 'John', - 'context' => 'business meeting', - 'formality' => 'formal', - ]; - $request = $this->createGetPromptRequest('personalized_prompt', $arguments); - $expectedMessages = [ - new PromptMessage( - Role::User, - new TextContent('Good morning, John. How may I assist you in your business meeting?'), - ), - ]; - $expectedResult = new GetPromptResult($expectedMessages); - - $promptReference = $this->createMock(PromptReference::class); - $this->referenceProvider - ->expects($this->once()) - ->method('getPrompt') - ->with('personalized_prompt') - ->willReturn($promptReference); - - $this->referenceHandler - ->expects($this->once()) - ->method('handle') - ->with($promptReference, array_merge($arguments, ['_session' => $this->session, '_request' => $request])) - ->willReturn($expectedMessages); - - $promptReference - ->expects($this->once()) - ->method('formatResult') - ->with($expectedMessages) - ->willReturn($expectedMessages); - - $response = $this->handler->handle($request, $this->session); - - $this->assertInstanceOf(Response::class, $response); - $this->assertEquals($expectedResult, $response->result); - } - - public function testHandlePromptGetWithNullArguments(): void - { - $request = $this->createGetPromptRequest('simple_prompt', null); - $expectedMessages = [ - new PromptMessage(Role::Assistant, new TextContent('I am ready to help.')), - ]; - $expectedResult = new GetPromptResult($expectedMessages); - - $promptReference = $this->createMock(PromptReference::class); - $this->referenceProvider - ->expects($this->once()) - ->method('getPrompt') - ->with('simple_prompt') - ->willReturn($promptReference); - - $this->referenceHandler - ->expects($this->once()) - ->method('handle') - ->with($promptReference, ['_session' => $this->session, '_request' => $request]) - ->willReturn($expectedMessages); - - $promptReference - ->expects($this->once()) - ->method('formatResult') - ->with($expectedMessages) - ->willReturn($expectedMessages); - - $response = $this->handler->handle($request, $this->session); - - $this->assertInstanceOf(Response::class, $response); - $this->assertEquals($expectedResult, $response->result); - } - - public function testHandlePromptGetWithEmptyArguments(): void - { - $request = $this->createGetPromptRequest('empty_args_prompt', []); - $expectedMessages = [ - new PromptMessage(Role::User, new TextContent('Default message')), - ]; - $expectedResult = new GetPromptResult($expectedMessages); - - $promptReference = $this->createMock(PromptReference::class); - $this->referenceProvider - ->expects($this->once()) - ->method('getPrompt') - ->with('empty_args_prompt') - ->willReturn($promptReference); - - $this->referenceHandler - ->expects($this->once()) - ->method('handle') - ->with($promptReference, ['_session' => $this->session, '_request' => $request]) - ->willReturn($expectedMessages); - - $promptReference - ->expects($this->once()) - ->method('formatResult') - ->with($expectedMessages) - ->willReturn($expectedMessages); - - $response = $this->handler->handle($request, $this->session); - - $this->assertInstanceOf(Response::class, $response); - $this->assertEquals($expectedResult, $response->result); - } - - public function testHandlePromptGetWithMultipleMessages(): void - { - $request = $this->createGetPromptRequest('conversation_prompt'); - $expectedMessages = [ - new PromptMessage(Role::User, new TextContent('Hello')), - new PromptMessage(Role::Assistant, new TextContent('Hi there! How can I help you today?')), - new PromptMessage(Role::User, new TextContent('I need assistance with my project')), - ]; - $expectedResult = new GetPromptResult($expectedMessages); - - $promptReference = $this->createMock(PromptReference::class); - $this->referenceProvider - ->expects($this->once()) - ->method('getPrompt') - ->with('conversation_prompt') - ->willReturn($promptReference); - - $this->referenceHandler - ->expects($this->once()) - ->method('handle') - ->with($promptReference, ['_session' => $this->session, '_request' => $request]) - ->willReturn($expectedMessages); - - $promptReference - ->expects($this->once()) - ->method('formatResult') - ->with($expectedMessages) - ->willReturn($expectedMessages); - - $response = $this->handler->handle($request, $this->session); - - $this->assertInstanceOf(Response::class, $response); - $this->assertEquals($expectedResult, $response->result); - } - - public function testHandlePromptNotFoundExceptionReturnsError(): void - { - $request = $this->createGetPromptRequest('nonexistent_prompt'); - $exception = new PromptNotFoundException('nonexistent_prompt'); - - $this->referenceProvider - ->expects($this->once()) - ->method('getPrompt') - ->with('nonexistent_prompt') - ->willThrowException($exception); - - $this->logger - ->expects($this->once()) - ->method('error') - ->with('Prompt not found', ['prompt_name' => 'nonexistent_prompt', 'exception' => $exception]); - - $response = $this->handler->handle($request, $this->session); - - $this->assertInstanceOf(Error::class, $response); - $this->assertEquals($request->getId(), $response->id); - $this->assertEquals(Error::RESOURCE_NOT_FOUND, $response->code); - $this->assertEquals('Prompt not found: "nonexistent_prompt".', $response->message); - } - - public function testHandlePromptGetExceptionReturnsError(): void - { - $request = $this->createGetPromptRequest('failing_prompt'); - $exception = new PromptGetException('Failed to get prompt'); - - $this->referenceProvider - ->expects($this->once()) - ->method('getPrompt') - ->with('failing_prompt') - ->willThrowException($exception); - - $this->logger - ->expects($this->once()) - ->method('error') - ->with('Error while handling prompt "failing_prompt": "Failed to get prompt".', ['exception' => $exception]); - - $response = $this->handler->handle($request, $this->session); - - $this->assertInstanceOf(Error::class, $response); - $this->assertEquals($request->getId(), $response->id); - $this->assertEquals(Error::INTERNAL_ERROR, $response->code); - $this->assertEquals('Failed to get prompt', $response->message); - } - - public function testHandlePromptGetWithComplexArguments(): void - { - $arguments = [ - 'user_data' => [ - 'name' => 'Alice', - 'preferences' => ['formal', 'concise'], - 'history' => [ - 'last_interaction' => '2025-01-15', - 'topics' => ['technology', 'business'], - ], - ], - 'context' => 'technical consultation', - 'metadata' => [ - 'session_id' => 'sess_123456', - 'timestamp' => 1705392000, - ], - ]; - $request = $this->createGetPromptRequest('complex_prompt', $arguments); - $expectedMessages = [ - new PromptMessage(Role::User, new TextContent('Complex prompt generated with all parameters')), - ]; - $expectedResult = new GetPromptResult($expectedMessages); - - $promptReference = $this->createMock(PromptReference::class); - $this->referenceProvider - ->expects($this->once()) - ->method('getPrompt') - ->with('complex_prompt') - ->willReturn($promptReference); - - $this->referenceHandler - ->expects($this->once()) - ->method('handle') - ->with($promptReference, array_merge($arguments, ['_session' => $this->session, '_request' => $request])) - ->willReturn($expectedMessages); - - $promptReference - ->expects($this->once()) - ->method('formatResult') - ->with($expectedMessages) - ->willReturn($expectedMessages); - - $response = $this->handler->handle($request, $this->session); - - $this->assertInstanceOf(Response::class, $response); - $this->assertEquals($expectedResult, $response->result); - } - - public function testHandlePromptGetWithSpecialCharacters(): void - { - $arguments = [ - 'message' => 'Hello 世界! How are you? 😊', - 'special' => 'äöü ñ ß', - 'quotes' => 'Text with "double" and \'single\' quotes', - ]; - $request = $this->createGetPromptRequest('unicode_prompt', $arguments); - $expectedMessages = [ - new PromptMessage(Role::User, new TextContent('Unicode message processed')), - ]; - $expectedResult = new GetPromptResult($expectedMessages); - - $promptReference = $this->createMock(PromptReference::class); - $this->referenceProvider - ->expects($this->once()) - ->method('getPrompt') - ->with('unicode_prompt') - ->willReturn($promptReference); - - $this->referenceHandler - ->expects($this->once()) - ->method('handle') - ->with($promptReference, array_merge($arguments, ['_session' => $this->session, '_request' => $request])) - ->willReturn($expectedMessages); - - $promptReference - ->expects($this->once()) - ->method('formatResult') - ->with($expectedMessages) - ->willReturn($expectedMessages); - - $response = $this->handler->handle($request, $this->session); - - $this->assertInstanceOf(Response::class, $response); - $this->assertEquals($expectedResult, $response->result); - } - - public function testHandlePromptGetReturnsEmptyMessages(): void - { - $request = $this->createGetPromptRequest('empty_prompt'); - $expectedResult = new GetPromptResult([]); - - $promptReference = $this->createMock(PromptReference::class); - $this->referenceProvider - ->expects($this->once()) - ->method('getPrompt') - ->with('empty_prompt') - ->willReturn($promptReference); - - $this->referenceHandler - ->expects($this->once()) - ->method('handle') - ->with($promptReference, ['_session' => $this->session, '_request' => $request]) - ->willReturn([]); - - $promptReference - ->expects($this->once()) - ->method('formatResult') - ->with([]) - ->willReturn([]); - - $response = $this->handler->handle($request, $this->session); - - $this->assertInstanceOf(Response::class, $response); - $this->assertEquals($expectedResult, $response->result); - } - - public function testHandlePromptGetWithLargeNumberOfArguments(): void - { - $arguments = []; - for ($i = 0; $i < 100; ++$i) { - $arguments["arg_{$i}"] = "value_{$i}"; - } - - $request = $this->createGetPromptRequest('many_args_prompt', $arguments); - $expectedMessages = [ - new PromptMessage(Role::User, new TextContent('Processed 100 arguments')), - ]; - $expectedResult = new GetPromptResult($expectedMessages); - - $promptReference = $this->createMock(PromptReference::class); - $this->referenceProvider - ->expects($this->once()) - ->method('getPrompt') - ->with('many_args_prompt') - ->willReturn($promptReference); - - $this->referenceHandler - ->expects($this->once()) - ->method('handle') - ->with($promptReference, array_merge($arguments, ['_session' => $this->session, '_request' => $request])) - ->willReturn($expectedMessages); - - $promptReference - ->expects($this->once()) - ->method('formatResult') - ->with($expectedMessages) - ->willReturn($expectedMessages); - - $response = $this->handler->handle($request, $this->session); - - $this->assertInstanceOf(Response::class, $response); - $this->assertEquals($expectedResult, $response->result); - } - - /** - * @param array|null $arguments - */ - private function createGetPromptRequest(string $name, ?array $arguments = null): GetPromptRequest - { - return GetPromptRequest::fromArray([ - 'jsonrpc' => '2.0', - 'method' => GetPromptRequest::getMethod(), - 'id' => 'test-request-'.uniqid(), - 'params' => [ - 'name' => $name, - 'arguments' => $arguments, - ], - ]); - } -} diff --git a/tests/Unit/Server/Handler/Request/InitializeHandlerTest.php b/tests/Unit/Server/Handler/Request/InitializeHandlerTest.php deleted file mode 100644 index 25df23b0..00000000 --- a/tests/Unit/Server/Handler/Request/InitializeHandlerTest.php +++ /dev/null @@ -1,205 +0,0 @@ -createMock(SessionInterface::class); - $session->expects($this->exactly(3)) - ->method('set') - ->willReturnCallback(function (string $key, mixed $value): void { - match ($key) { - 'client_info' => $this->assertSame(['name' => 'client-app', 'version' => '1.0.0'], $value), - 'client_capabilities' => $this->assertEquals(new \stdClass(), $value), - 'protocol_version' => $this->assertSame(ProtocolVersion::V2024_11_05->value, $value), - default => $this->fail("Unexpected session key: {$key}"), - }; - }); - - $request = $this->createInitializeRequest(ProtocolVersion::V2024_11_05->value); - - $response = $handler->handle($request, $session); - - $this->assertInstanceOf(InitializeResult::class, $response->result); - - /** @var InitializeResult $result */ - $result = $response->result; - - $this->assertSame($customProtocolVersion, $result->protocolVersion); - $this->assertSame( - $customProtocolVersion->value, - $result->jsonSerialize()['protocolVersion'] - ); - } - - #[TestDox('answers an unpinned handshake per the negotiation table')] - #[DataProvider('provideNegotiationTable')] - public function testNegotiationTable(string $requested, ProtocolVersion $expected): void - { - $handler = new InitializeHandler($this->createConfiguration()); - - $response = $handler->handle( - $this->createInitializeRequest($requested), - $this->createStub(SessionInterface::class), - ); - - \assert($response->result instanceof InitializeResult); - $this->assertNotNull($response->result->protocolVersion); - $this->assertSame($expected, $response->result->protocolVersion); - - // No row may resolve to a modern revision, whatever the client sent — that - // era has no `initialize`, so the answer would be unusable for both sides. - $this->assertFalse($response->result->protocolVersion->isModern()); - } - - /** - * The negotiation table from docs/server-builder.md, one data set per case a - * client can present. Keeping the rows in a single provider is what stops the - * documented table and the tested behaviour from drifting apart. - * - * @return iterable - */ - public static function provideNegotiationTable(): iterable - { - $counterOffer = ProtocolVersion::latestHandshake(); - - // Driven off the enum rather than a literal list, so a new revision is - // covered by the era it is declared in the moment it lands. - foreach (ProtocolVersion::handshakeVersions() as $version) { - yield \sprintf('supported %s -> echoed back', $version->value) => [$version->value, $version]; - } - - yield 'unknown future revision -> counter-offer' => ['2099-01-01', $counterOffer]; - yield 'not a revision at all -> counter-offer' => ['banana', $counterOffer]; - yield 'empty -> counter-offer' => ['', $counterOffer]; - - foreach (ProtocolVersion::modernVersions() as $version) { - yield \sprintf('modern %s -> counter-offer', $version->value) => [$version->value, $counterOffer]; - } - } - - #[TestDox('a modern version pinned in configuration cannot leak into the handshake')] - public function testModernConfiguredVersionFallsBackToHandshakeSet(): void - { - $handler = new InitializeHandler($this->createConfiguration(ProtocolVersion::V2026_07_28)); - - $response = $handler->handle( - $this->createInitializeRequest(ProtocolVersion::V2025_06_18->value), - $this->createStub(SessionInterface::class), - ); - - \assert($response->result instanceof InitializeResult); - $this->assertSame(ProtocolVersion::V2025_06_18, $response->result->protocolVersion); - } - - #[TestDox('a pinned version wins over a different version requested by the client')] - public function testPinnedVersionOverridesClientRequest(): void - { - $handler = new InitializeHandler($this->createConfiguration(ProtocolVersion::V2025_03_26)); - - $response = $handler->handle( - $this->createInitializeRequest(ProtocolVersion::V2025_11_25->value), - $this->createStub(SessionInterface::class), - ); - - \assert($response->result instanceof InitializeResult); - $this->assertSame(ProtocolVersion::V2025_03_26, $response->result->protocolVersion); - } - - #[TestDox('stores the negotiated version on the session')] - public function testStoresNegotiatedVersionOnSession(): void - { - $handler = new InitializeHandler($this->createConfiguration()); - - $stored = []; - $session = $this->createMock(SessionInterface::class); - $session->method('set')->willReturnCallback(static function (string $key, mixed $value) use (&$stored): void { - $stored[$key] = $value; - }); - - $handler->handle($this->createInitializeRequest(ProtocolVersion::V2025_06_18->value), $session); - - $this->assertSame(ProtocolVersion::V2025_06_18->value, $stored['protocol_version'] ?? null); - } - - #[TestDox('falls back to empty defaults when constructed without a configuration')] - public function testHandlesMissingConfiguration(): void - { - // The reads in handle() sit on the left of ??, which has isset semantics and - // therefore tolerates the null without a nullsafe operator. This test is what - // proves that, rather than the shape of the accessor. - $handler = new InitializeHandler(); - - $response = $handler->handle( - $this->createInitializeRequest(ProtocolVersion::V2025_06_18->value), - $this->createStub(SessionInterface::class), - ); - - \assert($response->result instanceof InitializeResult); - $this->assertEquals(new ServerCapabilities(), $response->result->capabilities); - $this->assertEquals(new Implementation(), $response->result->serverInfo); - $this->assertNull($response->result->instructions); - $this->assertSame(ProtocolVersion::V2025_06_18, $response->result->protocolVersion); - } - - private function createConfiguration(?ProtocolVersion $protocolVersion = null): Configuration - { - return new Configuration( - serverInfo: new Implementation('server', '1.2.3'), - capabilities: new ServerCapabilities(), - protocolVersion: $protocolVersion, - ); - } - - private function createInitializeRequest(string $protocolVersion): InitializeRequest - { - return InitializeRequest::fromArray([ - 'jsonrpc' => MessageInterface::JSONRPC_VERSION, - 'id' => 'request-1', - 'method' => InitializeRequest::getMethod(), - 'params' => [ - 'protocolVersion' => $protocolVersion, - 'capabilities' => [], - 'clientInfo' => [ - 'name' => 'client-app', - 'version' => '1.0.0', - ], - ], - ]); - } -} diff --git a/tests/Unit/Server/Handler/Request/ListPromptsHandlerTest.php b/tests/Unit/Server/Handler/Request/ListPromptsHandlerTest.php deleted file mode 100644 index e2fb610d..00000000 --- a/tests/Unit/Server/Handler/Request/ListPromptsHandlerTest.php +++ /dev/null @@ -1,247 +0,0 @@ -registry = new Registry(); - $this->handler = new ListPromptsHandler($this->registry, pageSize: 3); // Use small page size for testing - $this->session = new Session(new InMemorySessionStore()); - } - - #[TestDox('Returns first page when no cursor provided')] - public function testReturnsFirstPageWhenNoCursorProvided(): void - { - // Arrange - $this->addPromptsToRegistry(5); - $request = $this->createListPromptsRequest(); - - // Act - $response = $this->handler->handle($request, $this->session); - - // Assert - /** @var ListPromptsResult $result */ - $result = $response->result; - $this->assertInstanceOf(ListPromptsResult::class, $result); - $this->assertCount(3, $result->prompts); - $this->assertNotNull($result->nextCursor); - - $this->assertEquals('prompt_0', $result->prompts[0]->name); - $this->assertEquals('prompt_1', $result->prompts[1]->name); - $this->assertEquals('prompt_2', $result->prompts[2]->name); - } - - #[TestDox('Returns paginated prompts with cursor')] - public function testReturnsPaginatedPromptsWithCursor(): void - { - // Arrange - $this->addPromptsToRegistry(10); - $request = $this->createListPromptsRequest(cursor: null); - - // Act - $response = $this->handler->handle($request, $this->session); - - // Assert - /** @var ListPromptsResult $result */ - $result = $response->result; - $this->assertInstanceOf(ListPromptsResult::class, $result); - $this->assertCount(3, $result->prompts); - $this->assertNotNull($result->nextCursor); - - $this->assertEquals('prompt_0', $result->prompts[0]->name); - $this->assertEquals('prompt_1', $result->prompts[1]->name); - $this->assertEquals('prompt_2', $result->prompts[2]->name); - } - - #[TestDox('Returns second page with cursor')] - public function testReturnsSecondPageWithCursor(): void - { - // Arrange - $this->addPromptsToRegistry(10); - $firstPageRequest = $this->createListPromptsRequest(); - $firstPageResponse = $this->handler->handle($firstPageRequest, $this->session); - - /** @var ListPromptsResult $firstPageResult */ - $firstPageResult = $firstPageResponse->result; - $secondPageRequest = $this->createListPromptsRequest(cursor: $firstPageResult->nextCursor); - - // Act - $response = $this->handler->handle($secondPageRequest, $this->session); - - // Assert - /** @var ListPromptsResult $result */ - $result = $response->result; - $this->assertInstanceOf(ListPromptsResult::class, $result); - $this->assertCount(3, $result->prompts); - $this->assertNotNull($result->nextCursor); - - $this->assertEquals('prompt_3', $result->prompts[0]->name); - $this->assertEquals('prompt_4', $result->prompts[1]->name); - $this->assertEquals('prompt_5', $result->prompts[2]->name); - } - - #[TestDox('Returns last page with null cursor')] - public function testReturnsLastPageWithNullCursor(): void - { - // Arrange - $this->addPromptsToRegistry(5); - $firstPageRequest = $this->createListPromptsRequest(); - $firstPageResponse = $this->handler->handle($firstPageRequest, $this->session); - - /** @var ListPromptsResult $firstPageResult */ - $firstPageResult = $firstPageResponse->result; - $secondPageRequest = $this->createListPromptsRequest(cursor: $firstPageResult->nextCursor); - - // Act - $response = $this->handler->handle($secondPageRequest, $this->session); - - // Assert - /** @var ListPromptsResult $result */ - $result = $response->result; - $this->assertInstanceOf(ListPromptsResult::class, $result); - $this->assertCount(2, $result->prompts); - $this->assertNull($result->nextCursor); - - $this->assertEquals('prompt_3', $result->prompts[0]->name); - $this->assertEquals('prompt_4', $result->prompts[1]->name); - } - - #[TestDox('Handles empty registry')] - public function testHandlesEmptyRegistry(): void - { - // Arrange - $request = $this->createListPromptsRequest(); - - // Act - $response = $this->handler->handle($request, $this->session); - - // Assert - /** @var ListPromptsResult $result */ - $result = $response->result; - $this->assertInstanceOf(ListPromptsResult::class, $result); - $this->assertCount(0, $result->prompts); - $this->assertNull($result->nextCursor); - } - - #[TestDox('Throws exception for invalid cursor')] - public function testThrowsExceptionForInvalidCursor(): void - { - // Arrange - $this->addPromptsToRegistry(5); - $request = $this->createListPromptsRequest(cursor: 'invalid-cursor'); - - // Assert - $this->expectException(InvalidCursorException::class); - - // Act - $this->handler->handle($request, $this->session); - } - - #[TestDox('Throws exception for cursor beyond bounds')] - public function testThrowsExceptionForCursorBeyondBounds(): void - { - // Arrange - $this->addPromptsToRegistry(5); - $outOfBoundsCursor = base64_encode('1000'); - $request = $this->createListPromptsRequest(cursor: $outOfBoundsCursor); - - // Assert - $this->expectException(InvalidCursorException::class); - - // Act - $this->handler->handle($request, $this->session); - } - - #[TestDox('Handles cursor at exact boundary')] - public function testHandlesCursorAtExactBoundary(): void - { - // Arrange - $this->addPromptsToRegistry(6); - $exactBoundaryCursor = base64_encode('6'); // Exactly at the end - $request = $this->createListPromptsRequest(cursor: $exactBoundaryCursor); - - // Act - $response = $this->handler->handle($request, $this->session); - - // Assert - /** @var ListPromptsResult $result */ - $result = $response->result; - $this->assertInstanceOf(ListPromptsResult::class, $result); - $this->assertCount(0, $result->prompts); - $this->assertNull($result->nextCursor); - } - - #[TestDox('Maintains stable cursors across calls')] - public function testMaintainsStableCursorsAcrossCalls(): void - { - // Arrange - $this->addPromptsToRegistry(10); - - // Act - $request = $this->createListPromptsRequest(); - $response1 = $this->handler->handle($request, $this->session); - $response2 = $this->handler->handle($request, $this->session); - - // Assert - /** @var ListPromptsResult $result1 */ - $result1 = $response1->result; - /** @var ListPromptsResult $result2 */ - $result2 = $response2->result; - $this->assertEquals($result1->nextCursor, $result2->nextCursor); - $this->assertEquals($result1->prompts, $result2->prompts); - } - - private function addPromptsToRegistry(int $count): void - { - for ($i = 0; $i < $count; ++$i) { - $prompt = new Prompt( - name: "prompt_$i", - description: "Test prompt $i" - ); - - $this->registry->registerPrompt($prompt, static fn () => null); - } - } - - private function createListPromptsRequest(?string $cursor = null): ListPromptsRequest - { - $data = [ - 'jsonrpc' => '2.0', - 'id' => 'test-request-id', - 'method' => 'prompts/list', - ]; - - if (null !== $cursor) { - $data['params'] = ['cursor' => $cursor]; - } - - return ListPromptsRequest::fromArray($data); - } -} diff --git a/tests/Unit/Server/Handler/Request/ListResourceTemplatesHandlerTest.php b/tests/Unit/Server/Handler/Request/ListResourceTemplatesHandlerTest.php deleted file mode 100644 index ede74cbc..00000000 --- a/tests/Unit/Server/Handler/Request/ListResourceTemplatesHandlerTest.php +++ /dev/null @@ -1,217 +0,0 @@ -registry = new Registry(); - $this->handler = new ListResourceTemplatesHandler($this->registry, pageSize: 3); - $this->session = new Session(new InMemorySessionStore()); - } - - public function testReturnsFirstPageWhenNoCursorProvided(): void - { - // Arrange - $this->addResourcesToRegistry(5); - $request = $this->createListResourcesRequest(); - - // Act - $response = $this->handler->handle($request, $this->session); - - // Assert - /** @var ListResourceTemplatesResult $result */ - $result = $response->result; - $this->assertInstanceOf(ListResourceTemplatesResult::class, $result); - $this->assertCount(3, $result->resourceTemplates); - $this->assertNotNull($result->nextCursor); - - $this->assertEquals('resource://{test}/resource_0', $result->resourceTemplates[0]->uriTemplate); - $this->assertEquals('resource://{test}/resource_1', $result->resourceTemplates[1]->uriTemplate); - $this->assertEquals('resource://{test}/resource_2', $result->resourceTemplates[2]->uriTemplate); - } - - public function testReturnsSecondPageWithCursor(): void - { - // Arrange - $this->addResourcesToRegistry(10); - $firstPageRequest = $this->createListResourcesRequest(); - $firstPageResponse = $this->handler->handle($firstPageRequest, $this->session); - - /** @var ListResourceTemplatesResult $firstPageResult */ - $firstPageResult = $firstPageResponse->result; - $secondPageRequest = $this->createListResourcesRequest(cursor: $firstPageResult->nextCursor); - - // Act - $response = $this->handler->handle($secondPageRequest, $this->session); - - // Assert - /** @var ListResourceTemplatesResult $result */ - $result = $response->result; - $this->assertInstanceOf(ListResourceTemplatesResult::class, $result); - $this->assertCount(3, $result->resourceTemplates); - $this->assertNotNull($result->nextCursor); - - $this->assertEquals('resource://{test}/resource_3', $result->resourceTemplates[0]->uriTemplate); - $this->assertEquals('resource://{test}/resource_4', $result->resourceTemplates[1]->uriTemplate); - $this->assertEquals('resource://{test}/resource_5', $result->resourceTemplates[2]->uriTemplate); - } - - public function testReturnsLastPageWithNullCursor(): void - { - // Arrange - $this->addResourcesToRegistry(5); - $firstPageRequest = $this->createListResourcesRequest(); - $firstPageResponse = $this->handler->handle($firstPageRequest, $this->session); - - /** @var ListResourceTemplatesResult $firstPageResult */ - $firstPageResult = $firstPageResponse->result; - $secondPageRequest = $this->createListResourcesRequest(cursor: $firstPageResult->nextCursor); - - // Act - $response = $this->handler->handle($secondPageRequest, $this->session); - - // Assert - /** @var ListResourceTemplatesResult $result */ - $result = $response->result; - $this->assertInstanceOf(ListResourceTemplatesResult::class, $result); - $this->assertCount(2, $result->resourceTemplates); - $this->assertNull($result->nextCursor); - - $this->assertEquals('resource://{test}/resource_3', $result->resourceTemplates[0]->uriTemplate); - $this->assertEquals('resource://{test}/resource_4', $result->resourceTemplates[1]->uriTemplate); - } - - public function testHandlesEmptyRegistry(): void - { - // Arrange - $request = $this->createListResourcesRequest(); - - // Act - $response = $this->handler->handle($request, $this->session); - - // Assert - /** @var ListResourceTemplatesResult $result */ - $result = $response->result; - $this->assertInstanceOf(ListResourceTemplatesResult::class, $result); - $this->assertCount(0, $result->resourceTemplates); - $this->assertNull($result->nextCursor); - } - - public function testThrowsExceptionForInvalidCursor(): void - { - // Arrange - $this->addResourcesToRegistry(5); - $request = $this->createListResourcesRequest(cursor: 'invalid-cursor'); - - // Assert - $this->expectException(InvalidCursorException::class); - - // Act - $this->handler->handle($request, $this->session); - } - - public function testThrowsExceptionForCursorBeyondBounds(): void - { - // Arrange - $this->addResourcesToRegistry(5); - $outOfBoundsCursor = base64_encode('100'); - $request = $this->createListResourcesRequest(cursor: $outOfBoundsCursor); - - // Assert - $this->expectException(InvalidCursorException::class); - - // Act - $this->handler->handle($request, $this->session); - } - - public function testHandlesCursorAtExactBoundary(): void - { - // Arrange - $this->addResourcesToRegistry(6); - $exactBoundaryCursor = base64_encode('6'); - $request = $this->createListResourcesRequest(cursor: $exactBoundaryCursor); - - // Act - $response = $this->handler->handle($request, $this->session); - - // Assert - /** @var ListResourceTemplatesResult $result */ - $result = $response->result; - $this->assertInstanceOf(ListResourceTemplatesResult::class, $result); - $this->assertCount(0, $result->resourceTemplates); - $this->assertNull($result->nextCursor); - } - - public function testMaintainsStableCursorsAcrossCalls(): void - { - // Arrange - $this->addResourcesToRegistry(10); - - // Act - $request = $this->createListResourcesRequest(); - $response1 = $this->handler->handle($request, $this->session); - $response2 = $this->handler->handle($request, $this->session); - - // Assert - /** @var ListResourceTemplatesResult $result1 */ - $result1 = $response1->result; - /** @var ListResourceTemplatesResult $result2 */ - $result2 = $response2->result; - $this->assertEquals($result1->nextCursor, $result2->nextCursor); - $this->assertEquals($result1->resourceTemplates, $result2->resourceTemplates); - } - - private function addResourcesToRegistry(int $count): void - { - for ($i = 0; $i < $count; ++$i) { - $resourceTemplate = new ResourceTemplate( - uriTemplate: "resource://{test}/resource_$i", - name: "resource_$i", - description: "Test resource $i" - ); - // Use a simple callable as handler - $this->registry->registerResourceTemplate($resourceTemplate, static fn () => null); - } - } - - private function createListResourcesRequest(?string $cursor = null): ListResourceTemplatesRequest - { - $data = [ - 'jsonrpc' => '2.0', - 'id' => 'test-request-id', - 'method' => 'resources/list', - ]; - - if (null !== $cursor) { - $data['params'] = ['cursor' => $cursor]; - } - - return ListResourceTemplatesRequest::fromArray($data); - } -} diff --git a/tests/Unit/Server/Handler/Request/ListResourcesHandlerTest.php b/tests/Unit/Server/Handler/Request/ListResourcesHandlerTest.php deleted file mode 100644 index 3e385fef..00000000 --- a/tests/Unit/Server/Handler/Request/ListResourcesHandlerTest.php +++ /dev/null @@ -1,248 +0,0 @@ -registry = new Registry(); - $this->handler = new ListResourcesHandler($this->registry, pageSize: 3); // Use small page size for testing - $this->session = new Session(new InMemorySessionStore()); - } - - #[TestDox('Returns first page when no cursor provided')] - public function testReturnsFirstPageWhenNoCursorProvided(): void - { - // Arrange - $this->addResourcesToRegistry(5); - $request = $this->createListResourcesRequest(); - - // Act - $response = $this->handler->handle($request, $this->session); - - // Assert - /** @var ListResourcesResult $result */ - $result = $response->result; - $this->assertInstanceOf(ListResourcesResult::class, $result); - $this->assertCount(3, $result->resources); - $this->assertNotNull($result->nextCursor); - - $this->assertEquals('resource://test/resource_0', $result->resources[0]->uri); - $this->assertEquals('resource://test/resource_1', $result->resources[1]->uri); - $this->assertEquals('resource://test/resource_2', $result->resources[2]->uri); - } - - #[TestDox('Returns paginated resources with cursor')] - public function testReturnsPaginatedResourcesWithCursor(): void - { - // Arrange - $this->addResourcesToRegistry(10); - $request = $this->createListResourcesRequest(cursor: null); - - // Act - $response = $this->handler->handle($request, $this->session); - - // Assert - /** @var ListResourcesResult $result */ - $result = $response->result; - $this->assertInstanceOf(ListResourcesResult::class, $result); - $this->assertCount(3, $result->resources); - $this->assertNotNull($result->nextCursor); - - $this->assertEquals('resource://test/resource_0', $result->resources[0]->uri); - $this->assertEquals('resource://test/resource_1', $result->resources[1]->uri); - $this->assertEquals('resource://test/resource_2', $result->resources[2]->uri); - } - - #[TestDox('Returns second page with cursor')] - public function testReturnsSecondPageWithCursor(): void - { - // Arrange - $this->addResourcesToRegistry(10); - $firstPageRequest = $this->createListResourcesRequest(); - $firstPageResponse = $this->handler->handle($firstPageRequest, $this->session); - - /** @var ListResourcesResult $firstPageResult */ - $firstPageResult = $firstPageResponse->result; - $secondPageRequest = $this->createListResourcesRequest(cursor: $firstPageResult->nextCursor); - - // Act - $response = $this->handler->handle($secondPageRequest, $this->session); - - // Assert - /** @var ListResourcesResult $result */ - $result = $response->result; - $this->assertInstanceOf(ListResourcesResult::class, $result); - $this->assertCount(3, $result->resources); - $this->assertNotNull($result->nextCursor); - - $this->assertEquals('resource://test/resource_3', $result->resources[0]->uri); - $this->assertEquals('resource://test/resource_4', $result->resources[1]->uri); - $this->assertEquals('resource://test/resource_5', $result->resources[2]->uri); - } - - #[TestDox('Returns last page with null cursor')] - public function testReturnsLastPageWithNullCursor(): void - { - // Arrange - $this->addResourcesToRegistry(5); - $firstPageRequest = $this->createListResourcesRequest(); - $firstPageResponse = $this->handler->handle($firstPageRequest, $this->session); - - /** @var ListResourcesResult $firstPageResult */ - $firstPageResult = $firstPageResponse->result; - $secondPageRequest = $this->createListResourcesRequest(cursor: $firstPageResult->nextCursor); - - // Act - $response = $this->handler->handle($secondPageRequest, $this->session); - - // Assert - /** @var ListResourcesResult $result */ - $result = $response->result; - $this->assertInstanceOf(ListResourcesResult::class, $result); - $this->assertCount(2, $result->resources); - $this->assertNull($result->nextCursor); - - $this->assertEquals('resource://test/resource_3', $result->resources[0]->uri); - $this->assertEquals('resource://test/resource_4', $result->resources[1]->uri); - } - - #[TestDox('Handles empty registry')] - public function testHandlesEmptyRegistry(): void - { - // Arrange - $request = $this->createListResourcesRequest(); - - // Act - $response = $this->handler->handle($request, $this->session); - - // Assert - /** @var ListResourcesResult $result */ - $result = $response->result; - $this->assertInstanceOf(ListResourcesResult::class, $result); - $this->assertCount(0, $result->resources); - $this->assertNull($result->nextCursor); - } - - #[TestDox('Throws exception for invalid cursor')] - public function testThrowsExceptionForInvalidCursor(): void - { - // Arrange - $this->addResourcesToRegistry(5); - $request = $this->createListResourcesRequest(cursor: 'invalid-cursor'); - - // Assert - $this->expectException(InvalidCursorException::class); - - // Act - $this->handler->handle($request, $this->session); - } - - #[TestDox('Throws exception for cursor beyond bounds')] - public function testThrowsExceptionForCursorBeyondBounds(): void - { - // Arrange - $this->addResourcesToRegistry(5); - $outOfBoundsCursor = base64_encode('100'); - $request = $this->createListResourcesRequest(cursor: $outOfBoundsCursor); - - // Assert - $this->expectException(InvalidCursorException::class); - - // Act - $this->handler->handle($request, $this->session); - } - - #[TestDox('Handles cursor at exact boundary')] - public function testHandlesCursorAtExactBoundary(): void - { - // Arrange - $this->addResourcesToRegistry(6); - $exactBoundaryCursor = base64_encode('6'); - $request = $this->createListResourcesRequest(cursor: $exactBoundaryCursor); - - // Act - $response = $this->handler->handle($request, $this->session); - - // Assert - /** @var ListResourcesResult $result */ - $result = $response->result; - $this->assertInstanceOf(ListResourcesResult::class, $result); - $this->assertCount(0, $result->resources); - $this->assertNull($result->nextCursor); - } - - #[TestDox('Maintains stable cursors across calls')] - public function testMaintainsStableCursorsAcrossCalls(): void - { - // Arrange - $this->addResourcesToRegistry(10); - - // Act - $request = $this->createListResourcesRequest(); - $response1 = $this->handler->handle($request, $this->session); - $response2 = $this->handler->handle($request, $this->session); - - // Assert - /** @var ListResourcesResult $result1 */ - $result1 = $response1->result; - /** @var ListResourcesResult $result2 */ - $result2 = $response2->result; - $this->assertEquals($result1->nextCursor, $result2->nextCursor); - $this->assertEquals($result1->resources, $result2->resources); - } - - private function addResourcesToRegistry(int $count): void - { - for ($i = 0; $i < $count; ++$i) { - $resource = new ResourceDefinition( - uri: "resource://test/resource_$i", - name: "resource_$i", - description: "Test resource $i" - ); - // Use a simple callable as handler - $this->registry->registerResource($resource, static fn () => null); - } - } - - private function createListResourcesRequest(?string $cursor = null): ListResourcesRequest - { - $data = [ - 'jsonrpc' => '2.0', - 'id' => 'test-request-id', - 'method' => 'resources/list', - ]; - - if (null !== $cursor) { - $data['params'] = ['cursor' => $cursor]; - } - - return ListResourcesRequest::fromArray($data); - } -} diff --git a/tests/Unit/Server/Handler/Request/ListToolsHandlerTest.php b/tests/Unit/Server/Handler/Request/ListToolsHandlerTest.php deleted file mode 100644 index b97185ad..00000000 --- a/tests/Unit/Server/Handler/Request/ListToolsHandlerTest.php +++ /dev/null @@ -1,320 +0,0 @@ -registry = new Registry(); - $this->handler = new ListToolsHandler($this->registry, pageSize: 3); // Use small page size for testing - $this->session = new Session(new InMemorySessionStore()); - } - - #[TestDox('Returns first page when no cursor provided')] - public function testReturnsFirstPageWhenNoCursorProvided(): void - { - // Arrange - $this->addToolsToRegistry(5); - $request = $this->createListToolsRequest(); - - // Act - $response = $this->handler->handle($request, $this->session); - - // Assert - /** @var ListToolsResult $result */ - $result = $response->result; - $this->assertInstanceOf(ListToolsResult::class, $result); - $this->assertCount(3, $result->tools); - $this->assertNotNull($result->nextCursor); - - $this->assertEquals('tool_0', $result->tools[0]->name); - $this->assertEquals('tool_1', $result->tools[1]->name); - $this->assertEquals('tool_2', $result->tools[2]->name); - } - - #[TestDox('Returns paginated tools with cursor')] - public function testReturnsPaginatedToolsWithCursor(): void - { - // Arrange - $this->addToolsToRegistry(10); - $request = $this->createListToolsRequest(cursor: null); - - // Act - $response = $this->handler->handle($request, $this->session); - - // Assert - /** @var ListToolsResult $result */ - $result = $response->result; - $this->assertInstanceOf(ListToolsResult::class, $result); - $this->assertCount(3, $result->tools); - $this->assertNotNull($result->nextCursor); - - $this->assertEquals('tool_0', $result->tools[0]->name); - $this->assertEquals('tool_1', $result->tools[1]->name); - $this->assertEquals('tool_2', $result->tools[2]->name); - } - - #[TestDox('Returns second page with cursor')] - public function testReturnsSecondPageWithCursor(): void - { - // Arrange - $this->addToolsToRegistry(10); - $firstPageRequest = $this->createListToolsRequest(); - $firstPageResponse = $this->handler->handle($firstPageRequest, $this->session); - - /** @var ListToolsResult $firstPageResult */ - $firstPageResult = $firstPageResponse->result; - $secondPageRequest = $this->createListToolsRequest(cursor: $firstPageResult->nextCursor); - - // Act - $response = $this->handler->handle($secondPageRequest, $this->session); - - // Assert - /** @var ListToolsResult $result */ - $result = $response->result; - $this->assertInstanceOf(ListToolsResult::class, $result); - $this->assertCount(3, $result->tools); - $this->assertNotNull($result->nextCursor); - - $this->assertEquals('tool_3', $result->tools[0]->name); - $this->assertEquals('tool_4', $result->tools[1]->name); - $this->assertEquals('tool_5', $result->tools[2]->name); - } - - #[TestDox('Returns last page with null cursor')] - public function testReturnsLastPageWithNullCursor(): void - { - // Arrange - $this->addToolsToRegistry(5); - $firstPageRequest = $this->createListToolsRequest(); - $firstPageResponse = $this->handler->handle($firstPageRequest, $this->session); - - /** @var ListToolsResult $firstPageResult */ - $firstPageResult = $firstPageResponse->result; - $secondPageRequest = $this->createListToolsRequest(cursor: $firstPageResult->nextCursor); - - // Act - $response = $this->handler->handle($secondPageRequest, $this->session); - - // Assert - /** @var ListToolsResult $result */ - $result = $response->result; - $this->assertInstanceOf(ListToolsResult::class, $result); - $this->assertCount(2, $result->tools); - $this->assertNull($result->nextCursor); - - $this->assertEquals('tool_3', $result->tools[0]->name); - $this->assertEquals('tool_4', $result->tools[1]->name); - } - - #[TestDox('Returns all tools when count is less than page size')] - public function testReturnsAllToolsWhenCountIsLessThanPageSize(): void - { - // Arrange - $this->addToolsToRegistry(2); // Less than page size 3 - $request = $this->createListToolsRequest(); - - // Act - $response = $this->handler->handle($request, $this->session); - - // Assert - /** @var ListToolsResult $result */ - $result = $response->result; - $this->assertInstanceOf(ListToolsResult::class, $result); - $this->assertCount(2, $result->tools); - $this->assertNull($result->nextCursor); - - $this->assertEquals('tool_0', $result->tools[0]->name); - $this->assertEquals('tool_1', $result->tools[1]->name); - } - - #[TestDox('Handles empty registry')] - public function testHandlesEmptyRegistry(): void - { - // Arrange - $request = $this->createListToolsRequest(); - - // Act - $response = $this->handler->handle($request, $this->session); - - // Assert - /** @var ListToolsResult $result */ - $result = $response->result; - $this->assertInstanceOf(ListToolsResult::class, $result); - $this->assertCount(0, $result->tools); - $this->assertNull($result->nextCursor); - } - - #[TestDox('Throws exception for invalid cursor')] - public function testThrowsExceptionForInvalidCursor(): void - { - // Arrange - $this->addToolsToRegistry(5); - $request = $this->createListToolsRequest(cursor: 'invalid-cursor'); - - // Assert - $this->expectException(InvalidCursorException::class); - - // Act - $this->handler->handle($request, $this->session); - } - - #[TestDox('Throws exception for cursor beyond bounds')] - public function testThrowsExceptionForCursorBeyondBounds(): void - { - // Arrange - $this->addToolsToRegistry(5); - $outOfBoundsCursor = base64_encode('100'); - $request = $this->createListToolsRequest(cursor: $outOfBoundsCursor); - - // Assert - $this->expectException(InvalidCursorException::class); - - // Act - $this->handler->handle($request, $this->session); - } - - #[TestDox('Handles cursor at exact boundary')] - public function testHandlesCursorAtExactBoundary(): void - { - // Arrange - $this->addToolsToRegistry(6); - $exactBoundaryCursor = base64_encode('6'); - $request = $this->createListToolsRequest(cursor: $exactBoundaryCursor); - - // Act - $response = $this->handler->handle($request, $this->session); - - // Assert - /** @var ListToolsResult $result */ - $result = $response->result; - $this->assertInstanceOf(ListToolsResult::class, $result); - $this->assertCount(0, $result->tools); - $this->assertNull($result->nextCursor); - } - - #[TestDox('Maintains stable cursors across calls')] - public function testMaintainsStableCursorsAcrossCalls(): void - { - // Arrange - $this->addToolsToRegistry(10); - - // Act - $request = $this->createListToolsRequest(); - $response1 = $this->handler->handle($request, $this->session); - $response2 = $this->handler->handle($request, $this->session); - - // Assert - /** @var ListToolsResult $result1 */ - $result1 = $response1->result; - /** @var ListToolsResult $result2 */ - $result2 = $response2->result; - $this->assertEquals($result1->nextCursor, $result2->nextCursor); - $this->assertEquals($result1->tools, $result2->tools); - } - - #[TestDox('Uses custom page size when provided')] - public function testUsesCustomPageSizeWhenProvided(): void - { - // Arrange - $customPageSize = 5; - $customHandler = new ListToolsHandler($this->registry, pageSize: $customPageSize); - $this->addToolsToRegistry(10); - $request = $this->createListToolsRequest(); - - // Act - $response = $customHandler->handle($request, $this->session); - - // Assert - /** @var ListToolsResult $result */ - $result = $response->result; - $this->assertInstanceOf(ListToolsResult::class, $result); - $this->assertCount($customPageSize, $result->tools); - $this->assertNotNull($result->nextCursor); - } - - #[TestDox('Different page sizes produce different pagination results')] - public function testDifferentPageSizesProduceDifferentPaginationResults(): void - { - // Arrange - $this->addToolsToRegistry(10); - $smallPageHandler = new ListToolsHandler($this->registry, pageSize: 2); - $largePageHandler = new ListToolsHandler($this->registry, pageSize: 7); - $request = $this->createListToolsRequest(); - - // Act - $smallPageResponse = $smallPageHandler->handle($request, $this->session); - $largePageResponse = $largePageHandler->handle($request, $this->session); - - // Assert - /** @var ListToolsResult $smallResult */ - $smallResult = $smallPageResponse->result; - /** @var ListToolsResult $largeResult */ - $largeResult = $largePageResponse->result; - - $this->assertCount(2, $smallResult->tools); - $this->assertCount(7, $largeResult->tools); - $this->assertNotNull($smallResult->nextCursor); - $this->assertNotNull($largeResult->nextCursor); - } - - private function addToolsToRegistry(int $count): void - { - for ($i = 0; $i < $count; ++$i) { - $tool = new Tool( - name: "tool_$i", - title: null, - inputSchema: [ - 'type' => 'object', - 'properties' => [], - 'required' => [], - ], - description: "Test tool $i", - annotations: null - ); - - $this->registry->registerTool($tool, static fn () => null); - } - } - - private function createListToolsRequest(?string $cursor = null): ListToolsRequest - { - $data = [ - 'jsonrpc' => '2.0', - 'id' => 'test-request-id', - 'method' => 'tools/list', - ]; - - if (null !== $cursor) { - $data['params'] = ['cursor' => $cursor]; - } - - return ListToolsRequest::fromArray($data); - } -} diff --git a/tests/Unit/Server/Handler/Request/PingHandlerTest.php b/tests/Unit/Server/Handler/Request/PingHandlerTest.php deleted file mode 100644 index 2a33ecd1..00000000 --- a/tests/Unit/Server/Handler/Request/PingHandlerTest.php +++ /dev/null @@ -1,150 +0,0 @@ -session = $this->createMock(SessionInterface::class); - $this->handler = new PingHandler(); - } - - public function testSupportsPingRequest(): void - { - $request = $this->createPingRequest(); - - $this->assertTrue($this->handler->supports($request)); - } - - public function testHandlePingRequest(): void - { - $request = $this->createPingRequest(); - - $response = $this->handler->handle($request, $this->session); - - $this->assertInstanceOf(Response::class, $response); - $this->assertEquals($request->getId(), $response->id); - $this->assertInstanceOf(EmptyResult::class, $response->result); - } - - public function testHandleMultiplePingRequests(): void - { - $request1 = $this->createPingRequest(); - $request2 = $this->createPingRequest(); - - $response1 = $this->handler->handle($request1, $this->session); - $response2 = $this->handler->handle($request2, $this->session); - - $this->assertInstanceOf(Response::class, $response1); - $this->assertInstanceOf(Response::class, $response2); - $this->assertInstanceOf(EmptyResult::class, $response1->result); - $this->assertInstanceOf(EmptyResult::class, $response2->result); - $this->assertEquals($request1->getId(), $response1->id); - $this->assertEquals($request2->getId(), $response2->id); - } - - public function testHandlerHasNoSideEffects(): void - { - $request = $this->createPingRequest(); - - // Handle same request multiple times - $response1 = $this->handler->handle($request, $this->session); - $response2 = $this->handler->handle($request, $this->session); - - // Both responses should be identical - $this->assertEquals($response1->id, $response2->id); - $this->assertEquals( - \get_class($response1->result), - \get_class($response2->result), - ); - } - - public function testEmptyResultIsCorrectType(): void - { - $request = $this->createPingRequest(); - $response = $this->handler->handle($request, $this->session); - - $this->assertInstanceOf(EmptyResult::class, $response->result); - - // Verify EmptyResult serializes to empty object - $serialized = json_encode($response->result); - $this->assertEquals('{}', $serialized); - } - - public function testHandlerIsStateless(): void - { - $handler1 = new PingHandler(); - $handler2 = new PingHandler(); - - $request = $this->createPingRequest(); - - $response1 = $handler1->handle($request, $this->session); - $response2 = $handler2->handle($request, $this->session); - - // Both handlers should produce equivalent results - $this->assertEquals($response1->id, $response2->id); - $this->assertEquals( - \get_class($response1->result), - \get_class($response2->result), - ); - } - - public function testSupportsMethodIsConsistent(): void - { - $request = $this->createPingRequest(); - - // Multiple calls to supports should return same result - $this->assertTrue($this->handler->supports($request)); - $this->assertTrue($this->handler->supports($request)); - $this->assertTrue($this->handler->supports($request)); - } - - public function testHandlerCanBeReused(): void - { - $requests = []; - $responses = []; - - // Create multiple ping requests - for ($i = 0; $i < 5; ++$i) { - $requests[$i] = $this->createPingRequest(); - $responses[$i] = $this->handler->handle($requests[$i], $this->session); - } - - // All responses should be valid - foreach ($responses as $i => $response) { - $this->assertInstanceOf(Response::class, $response); - $this->assertEquals($requests[$i]->getId(), $response->id); - $this->assertInstanceOf(EmptyResult::class, $response->result); - } - } - - private function createPingRequest(): Request - { - return PingRequest::fromArray([ - 'jsonrpc' => '2.0', - 'method' => PingRequest::getMethod(), - 'id' => 'test-request-'.uniqid(), - ]); - } -} diff --git a/tests/Unit/Server/Handler/Request/ReadResourceHandlerTest.php b/tests/Unit/Server/Handler/Request/ReadResourceHandlerTest.php deleted file mode 100644 index efdc9142..00000000 --- a/tests/Unit/Server/Handler/Request/ReadResourceHandlerTest.php +++ /dev/null @@ -1,446 +0,0 @@ -registry = $this->createMock(RegistryInterface::class); - $this->referenceHandler = $this->createMock(ReferenceHandlerInterface::class); - $this->session = $this->createMock(SessionInterface::class); - $this->logger = $this->createMock(LoggerInterface::class); - - $this->handler = new ReadResourceHandler($this->registry, $this->referenceHandler, $this->logger); - } - - public function testSupportsReadResourceRequest(): void - { - $request = $this->createReadResourceRequest('file://test.txt'); - - $this->assertTrue($this->handler->supports($request)); - } - - public function testHandleSuccessfulResourceRead(): void - { - $uri = 'file://documents/readme.txt'; - $request = $this->createReadResourceRequest($uri); - $expectedContent = new TextResourceContents( - uri: $uri, - mimeType: 'text/plain', - text: 'This is the content of the readme file.', - ); - $expectedResult = new ReadResourceResult([$expectedContent]); - - $resourceReference = $this->getMockBuilder(ResourceReference::class) - ->setConstructorArgs([new ResourceDefinition($uri, 'test', mimeType: 'text/plain'), []]) - ->getMock(); - - $this->registry - ->expects($this->once()) - ->method('getResource') - ->with($uri) - ->willReturn($resourceReference); - - $this->referenceHandler - ->expects($this->once()) - ->method('handle') - ->with($resourceReference, ['uri' => $uri, '_session' => $this->session, '_request' => $request]) - ->willReturn('test'); - - $resourceReference - ->expects($this->once()) - ->method('formatResult') - ->with('test', $uri, 'text/plain') - ->willReturn([$expectedContent]); - - $response = $this->handler->handle($request, $this->session); - - $this->assertInstanceOf(Response::class, $response); - $this->assertEquals($request->getId(), $response->id); - $this->assertEquals($expectedResult, $response->result); - } - - public function testHandleResourceReadWithBlobContent(): void - { - $uri = 'file://images/logo.png'; - $request = $this->createReadResourceRequest($uri); - $expectedContent = new BlobResourceContents( - uri: $uri, - mimeType: 'image/png', - blob: base64_encode('fake-image-data'), - ); - $expectedResult = new ReadResourceResult([$expectedContent]); - - $resourceReference = $this->getMockBuilder(ResourceReference::class) - ->setConstructorArgs([new ResourceDefinition($uri, 'test', mimeType: 'image/png'), []]) - ->getMock(); - - $this->registry - ->expects($this->once()) - ->method('getResource') - ->with($uri) - ->willReturn($resourceReference); - - $this->referenceHandler - ->expects($this->once()) - ->method('handle') - ->with($resourceReference, ['uri' => $uri, '_session' => $this->session, '_request' => $request]) - ->willReturn('fake-image-data'); - - $resourceReference - ->expects($this->once()) - ->method('formatResult') - ->with('fake-image-data', $uri, 'image/png') - ->willReturn([$expectedContent]); - - $response = $this->handler->handle($request, $this->session); - - $this->assertInstanceOf(Response::class, $response); - $this->assertEquals($expectedResult, $response->result); - } - - public function testHandleResourceReadWithMultipleContents(): void - { - $uri = 'app://data/mixed-content'; - $request = $this->createReadResourceRequest($uri); - $textContent = new TextResourceContents( - uri: $uri, - mimeType: 'text/plain', - text: 'Text part of the resource', - ); - $blobContent = new BlobResourceContents( - uri: $uri, - mimeType: 'application/octet-stream', - blob: base64_encode('binary-data'), - ); - $expectedResult = new ReadResourceResult([$textContent, $blobContent]); - - $resourceReference = $this->getMockBuilder(ResourceReference::class) - ->setConstructorArgs([new ResourceDefinition($uri, 'test', mimeType: 'application/octet-stream'), []]) - ->getMock(); - - $this->registry - ->expects($this->once()) - ->method('getResource') - ->with($uri) - ->willReturn($resourceReference); - - $this->referenceHandler - ->expects($this->once()) - ->method('handle') - ->with($resourceReference, ['uri' => $uri, '_session' => $this->session, '_request' => $request]) - ->willReturn('binary-data'); - - $resourceReference - ->expects($this->once()) - ->method('formatResult') - ->with('binary-data', $uri, 'application/octet-stream') - ->willReturn([$textContent, $blobContent]); - - $response = $this->handler->handle($request, $this->session); - - $this->assertInstanceOf(Response::class, $response); - $this->assertEquals($expectedResult, $response->result); - } - - public function testHandleResourceNotFoundExceptionReturnsSpecificError(): void - { - $uri = 'file://nonexistent/file.txt'; - $request = $this->createReadResourceRequest($uri); - $exception = new ResourceNotFoundException($uri); - - $this->registry - ->expects($this->once()) - ->method('getResource') - ->with($uri) - ->willThrowException($exception); - - $this->logger - ->expects($this->once()) - ->method('error') - ->with('Resource not found', ['uri' => $uri, 'exception' => $exception]); - - $response = $this->handler->handle($request, $this->session); - - $this->assertInstanceOf(Error::class, $response); - $this->assertEquals($request->getId(), $response->id); - $this->assertEquals(Error::RESOURCE_NOT_FOUND, $response->code); - $this->assertEquals('Resource not found for uri: "'.$uri.'".', $response->message); - } - - public function testHandleResourceReadExceptionReturnsActualErrorMessage(): void - { - $uri = 'file://corrupted/file.txt'; - $request = $this->createReadResourceRequest($uri); - $exception = new ResourceReadException('Failed to read resource: corrupted data'); - - $this->registry - ->expects($this->once()) - ->method('getResource') - ->with($uri) - ->willThrowException($exception); - - $this->logger - ->expects($this->once()) - ->method('error') - ->with('Error while reading resource "file://corrupted/file.txt": "Failed to read resource: corrupted data".', ['exception' => $exception]); - - $response = $this->handler->handle($request, $this->session); - - $this->assertInstanceOf(Error::class, $response); - $this->assertEquals($request->getId(), $response->id); - $this->assertEquals(Error::INTERNAL_ERROR, $response->code); - $this->assertEquals('Failed to read resource: corrupted data', $response->message); - } - - public function testHandleGenericExceptionReturnsGenericError(): void - { - $uri = 'file://problematic/file.txt'; - $request = $this->createReadResourceRequest($uri); - $exception = new \RuntimeException('Internal database connection failed'); - - $this->registry - ->expects($this->once()) - ->method('getResource') - ->with($uri) - ->willThrowException($exception); - - $this->logger - ->expects($this->once()) - ->method('error') - ->with('Unexpected error while reading resource "file://problematic/file.txt": "Internal database connection failed".', ['exception' => $exception]); - - $response = $this->handler->handle($request, $this->session); - - $this->assertInstanceOf(Error::class, $response); - $this->assertEquals($request->getId(), $response->id); - $this->assertEquals(Error::INTERNAL_ERROR, $response->code); - $this->assertEquals('Error while reading resource', $response->message); - } - - public function testHandleResourceReadWithDifferentUriSchemes(): void - { - $uriSchemes = [ - 'file://local/path/file.txt', - 'http://example.com/resource', - 'https://secure.example.com/api/data', - 'ftp://files.example.com/document.pdf', - 'app://internal/resource/123', - 'custom-scheme://special/resource', - ]; - - foreach ($uriSchemes as $uri) { - $request = $this->createReadResourceRequest($uri); - $expectedContent = new TextResourceContents( - uri: $uri, - mimeType: 'text/plain', - text: "Content for {$uri}", - ); - $expectedResult = new ReadResourceResult([$expectedContent]); - - $resourceReference = $this->getMockBuilder(ResourceReference::class) - ->setConstructorArgs([new ResourceDefinition($uri, 'test', mimeType: 'text/plain'), []]) - ->getMock(); - - $this->registry - ->expects($this->once()) - ->method('getResource') - ->with($uri) - ->willReturn($resourceReference); - - $this->referenceHandler - ->expects($this->once()) - ->method('handle') - ->with($resourceReference, ['uri' => $uri, '_session' => $this->session, '_request' => $request]) - ->willReturn('test'); - - $resourceReference - ->expects($this->once()) - ->method('formatResult') - ->with('test', $uri, 'text/plain') - ->willReturn([$expectedContent]); - - $response = $this->handler->handle($request, $this->session); - - $this->assertInstanceOf(Response::class, $response); - $this->assertEquals($expectedResult, $response->result); - - // Reset the mock for next iteration - $this->registry = $this->createMock(RegistryInterface::class); - $this->referenceHandler = $this->createMock(ReferenceHandlerInterface::class); - $this->handler = new ReadResourceHandler($this->registry, $this->referenceHandler); - } - } - - public function testHandleResourceReadWithEmptyContent(): void - { - $uri = 'file://empty/file.txt'; - $request = $this->createReadResourceRequest($uri); - $expectedContent = new TextResourceContents( - uri: $uri, - mimeType: 'text/plain', - text: '', - ); - $expectedResult = new ReadResourceResult([$expectedContent]); - - $resourceReference = $this->getMockBuilder(ResourceReference::class) - ->setConstructorArgs([new ResourceDefinition($uri, 'test', mimeType: 'text/plain'), []]) - ->getMock(); - - $this->registry - ->expects($this->once()) - ->method('getResource') - ->with($uri) - ->willReturn($resourceReference); - - $this->referenceHandler - ->expects($this->once()) - ->method('handle') - ->with($resourceReference, ['uri' => $uri, '_session' => $this->session, '_request' => $request]) - ->willReturn(''); - - $resourceReference - ->expects($this->once()) - ->method('formatResult') - ->with('', $uri, 'text/plain') - ->willReturn([$expectedContent]); - - $response = $this->handler->handle($request, $this->session); - - $this->assertInstanceOf(Response::class, $response); - $this->assertEquals($expectedResult, $response->result); - } - - public function testHandleResourceReadWithDifferentMimeTypes(): void - { - $mimeTypes = [ - 'text/plain', - 'text/html', - 'application/json', - 'application/xml', - 'image/png', - 'image/jpeg', - 'application/pdf', - 'video/mp4', - 'audio/mpeg', - 'application/octet-stream', - ]; - - foreach ($mimeTypes as $i => $mimeType) { - $uri = "file://test/file{$i}"; - $request = $this->createReadResourceRequest($uri); - - if (str_starts_with($mimeType, 'text/') || str_starts_with($mimeType, 'application/json')) { - $expectedContent = new TextResourceContents( - uri: $uri, - mimeType: $mimeType, - text: "Content for {$mimeType}", - ); - } else { - $expectedContent = new BlobResourceContents( - uri: $uri, - mimeType: $mimeType, - blob: base64_encode("binary-content-for-{$mimeType}"), - ); - } - $expectedResult = new ReadResourceResult([$expectedContent]); - - $resourceReference = $this->getMockBuilder(ResourceReference::class) - ->setConstructorArgs([new ResourceDefinition($uri, 'test', mimeType: $mimeType), []]) - ->getMock(); - - $this->registry - ->expects($this->once()) - ->method('getResource') - ->with($uri) - ->willReturn($resourceReference); - - $this->referenceHandler - ->expects($this->once()) - ->method('handle') - ->with($resourceReference, ['uri' => $uri, '_session' => $this->session, '_request' => $request]) - ->willReturn($expectedContent); - - $resourceReference - ->expects($this->once()) - ->method('formatResult') - ->with($expectedContent, $uri, $mimeType) - ->willReturn([$expectedContent]); - - $response = $this->handler->handle($request, $this->session); - - $this->assertInstanceOf(Response::class, $response); - $this->assertEquals($expectedResult, $response->result); - - // Reset the mock for next iteration - $this->registry = $this->createMock(RegistryInterface::class); - $this->referenceHandler = $this->createMock(ReferenceHandlerInterface::class); - $this->handler = new ReadResourceHandler($this->registry, $this->referenceHandler); - } - } - - public function testHandleResourceNotFoundWithCustomMessage(): void - { - $uri = 'file://custom/missing.txt'; - $request = $this->createReadResourceRequest($uri); - $exception = new ResourceNotFoundException($uri); - - $this->registry - ->expects($this->once()) - ->method('getResource') - ->with($uri) - ->willThrowException($exception); - - $response = $this->handler->handle($request, $this->session); - - $this->assertInstanceOf(Error::class, $response); - $this->assertEquals(Error::RESOURCE_NOT_FOUND, $response->code); - $this->assertEquals('Resource not found for uri: "'.$uri.'".', $response->message); - } - - private function createReadResourceRequest(string $uri): ReadResourceRequest - { - return ReadResourceRequest::fromArray([ - 'jsonrpc' => '2.0', - 'method' => ReadResourceRequest::getMethod(), - 'id' => 'test-request-'.uniqid(), - 'params' => [ - 'uri' => $uri, - ], - ]); - } -} diff --git a/tests/Unit/Server/Handler/Request/ResourceSubscribeTest.php b/tests/Unit/Server/Handler/Request/ResourceSubscribeTest.php deleted file mode 100644 index b7d491b5..00000000 --- a/tests/Unit/Server/Handler/Request/ResourceSubscribeTest.php +++ /dev/null @@ -1,150 +0,0 @@ -registry = $this->createMock(RegistryInterface::class); - $this->subscriptionManager = $this->createMock(SubscriptionManagerInterface::class); - $this->session = $this->createMock(SessionInterface::class); - $this->logger = $this->createMock(LoggerInterface::class); - $this->handler = new ResourceSubscribeHandler($this->registry, $this->subscriptionManager, $this->logger); - } - - #[TestDox('Client can successfully subscribe to a resource')] - public function testClientCanSuccessfulSubscribeToAResource(): void - { - $uri = 'file://documents/readme.txt'; - $request = $this->createResourceSubscribeRequest($uri); - $resourceReference = $this->getMockBuilder(ResourceReference::class) - ->setConstructorArgs([new ResourceDefinition($uri, 'test', mimeType: 'text/plain'), []]) - ->getMock(); - - $this->registry - ->expects($this->once()) - ->method('getResource') - ->with($uri) - ->willReturn($resourceReference); - - $this->subscriptionManager->expects($this->once()) - ->method('subscribe') - ->with($this->session, $uri); - - $response = $this->handler->handle($request, $this->session); - - $this->assertInstanceOf(Response::class, $response); - $this->assertEquals($request->getId(), $response->id); - $this->assertInstanceOf(EmptyResult::class, $response->result); - } - - #[TestDox('Gracefully handle duplicate subscription to a resource')] - public function testDuplicateSubscriptionIsGracefullyHandled(): void - { - $uri = 'file://documents/readme.txt'; - $request = $this->createResourceSubscribeRequest($uri); - $resourceReference = $this->getMockBuilder(ResourceReference::class) - ->setConstructorArgs([new ResourceDefinition($uri, 'test', mimeType: 'text/plain'), []]) - ->getMock(); - - $this->registry - ->expects($this->exactly(2)) - ->method('getResource') - ->with($uri) - ->willReturn($resourceReference); - - $this->subscriptionManager - ->expects($this->exactly(2)) - ->method('subscribe') - ->with($this->session, $uri); - - $response1 = $this->handler->handle($request, $this->session); - $response2 = $this->handler->handle($request, $this->session); - - $this->assertInstanceOf(Response::class, $response1); - $this->assertInstanceOf(Response::class, $response2); - $this->assertEquals($request->getId(), $response1->id); - $this->assertEquals($request->getId(), $response2->id); - $this->assertInstanceOf(EmptyResult::class, $response1->result); - $this->assertInstanceOf(EmptyResult::class, $response2->result); - } - - #[TestDox('Subscription to a resource with an empty uri throws InvalidArgumentException')] - public function testSubscribeWithEmptyUriThrowsError(): void - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Missing or invalid "uri" parameter for resources/subscribe.'); - - $this->createResourceSubscribeRequest(''); - } - - #[TestDox('Subscription to a resource with an invalid uri throws ResourceNotException')] - public function testHandleSubscribeResourceNotFoundException(): void - { - $uri = 'file://missing/file.txt'; - $request = $this->createResourceSubscribeRequest($uri); - $exception = new ResourceNotFoundException($uri); - - $this->registry - ->expects($this->once()) - ->method('getResource') - ->with($uri) - ->willThrowException($exception); - - $this->logger - ->expects($this->once()) - ->method('error') - ->with('Resource not found', ['uri' => $uri, 'exception' => $exception]); - - $response = $this->handler->handle($request, $this->session); - - $this->assertInstanceOf(Error::class, $response); - $this->assertEquals(Error::RESOURCE_NOT_FOUND, $response->code); - $this->assertEquals(\sprintf('Resource not found for uri: "%s".', $uri), $response->message); - } - - private function createResourceSubscribeRequest(string $uri): ResourceSubscribeRequest - { - return ResourceSubscribeRequest::fromArray([ - 'jsonrpc' => '2.0', - 'method' => ResourceSubscribeRequest::getMethod(), - 'id' => 'test-request-'.uniqid(), - 'params' => [ - 'uri' => $uri, - ], - ]); - } -} diff --git a/tests/Unit/Server/Handler/Request/ResourceUnsubscribeTest.php b/tests/Unit/Server/Handler/Request/ResourceUnsubscribeTest.php deleted file mode 100644 index 5586d055..00000000 --- a/tests/Unit/Server/Handler/Request/ResourceUnsubscribeTest.php +++ /dev/null @@ -1,157 +0,0 @@ -registry = $this->createMock(RegistryInterface::class); - $this->subscriptionManager = $this->createMock(SubscriptionManagerInterface::class); - $this->session = $this->createMock(SessionInterface::class); - $this->logger = $this->createMock(LoggerInterface::class); - - $this->handler = new ResourceUnsubscribeHandler($this->registry, $this->subscriptionManager, $this->logger); - } - - #[TestDox('Client can unsubscribe from a resource')] - public function testClientCanUnsubscribeFromAResource(): void - { - // Arrange - $uri = 'file://documents/readme.txt'; - $request = $this->createResourceUnsubscribeRequest($uri); - $resourceReference = $this->getMockBuilder(ResourceReference::class) - ->setConstructorArgs([new ResourceDefinition($uri, 'test', mimeType: 'text/plain'), []]) - ->getMock(); - - $this->registry - ->expects($this->once()) - ->method('getResource') - ->with($uri) - ->willReturn($resourceReference); - - $this->subscriptionManager->expects($this->once()) - ->method('unsubscribe') - ->with($this->session, $uri); - - // Act - $response = $this->handler->handle($request, $this->session); - - // Assert - $this->assertInstanceOf(Response::class, $response); - $this->assertEquals($request->getId(), $response->id); - $this->assertInstanceOf(EmptyResult::class, $response->result); - } - - #[TestDox('Gracefully handle duplicate unsubscription from a resource')] - public function testDuplicateUnSubscriptionIsGracefullyHandled(): void - { - // Arrange - $uri = 'file://documents/readme.txt'; - $request = $this->createResourceUnsubscribeRequest($uri); - $resourceReference = $this->getMockBuilder(ResourceReference::class) - ->setConstructorArgs([new ResourceDefinition($uri, 'test', mimeType: 'text/plain'), []]) - ->getMock(); - - $this->registry - ->expects($this->exactly(2)) - ->method('getResource') - ->with($uri) - ->willReturn($resourceReference); - - $this->subscriptionManager - ->expects($this->exactly(2)) - ->method('unsubscribe') - ->with($this->session, $uri); - - // Act - $response1 = $this->handler->handle($request, $this->session); - $response2 = $this->handler->handle($request, $this->session); - - // Assert - $this->assertInstanceOf(Response::class, $response1); - $this->assertInstanceOf(Response::class, $response2); - $this->assertEquals($request->getId(), $response1->id); - $this->assertEquals($request->getId(), $response2->id); - $this->assertInstanceOf(EmptyResult::class, $response1->result); - $this->assertInstanceOf(EmptyResult::class, $response2->result); - } - - #[TestDox('Unsubscription from a resource with an invalid uri throws ResourceNotException')] - public function testHandleUnsubscribeResourceNotFoundException(): void - { - $uri = 'file://missing/file.txt'; - $request = $this->createResourceUnsubscribeRequest($uri); - $exception = new ResourceNotFoundException($uri); - - $this->registry - ->expects($this->once()) - ->method('getResource') - ->with($uri) - ->willThrowException($exception); - - $this->logger - ->expects($this->once()) - ->method('error') - ->with('Resource not found', ['uri' => $uri, 'exception' => $exception]); - - $response = $this->handler->handle($request, $this->session); - - $this->assertInstanceOf(Error::class, $response); - $this->assertEquals(Error::RESOURCE_NOT_FOUND, $response->code); - $this->assertEquals(\sprintf('Resource not found for uri: "%s".', $uri), $response->message); - } - - #[TestDox('Unsubscription from a resource with an empty uri throws InvalidArgumentException')] - public function testUnsubscribeWithEmptyUriThrowsError(): void - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Missing or invalid "uri" parameter for resources/unsubscribe.'); - - $this->createResourceUnsubscribeRequest(''); - } - - private function createResourceUnsubscribeRequest(string $uri): ResourceUnsubscribeRequest - { - return ResourceUnsubscribeRequest::fromArray([ - 'jsonrpc' => '2.0', - 'method' => ResourceUnsubscribeRequest::getMethod(), - 'id' => 'test-request-'.uniqid(), - 'params' => [ - 'uri' => $uri, - ], - ]); - } -} diff --git a/tests/Unit/Server/Handler/Request/SetLogLevelHandlerTest.php b/tests/Unit/Server/Handler/Request/SetLogLevelHandlerTest.php deleted file mode 100644 index 6aca135a..00000000 --- a/tests/Unit/Server/Handler/Request/SetLogLevelHandlerTest.php +++ /dev/null @@ -1,74 +0,0 @@ - - */ -class SetLogLevelHandlerTest extends TestCase -{ - public function testSupports(): void - { - $request = $this->createSetLogLevelRequest(LoggingLevel::Info); - $handler = new SetLogLevelHandler(); - $this->assertTrue($handler->supports($request)); - } - - public function testDoesNotSupportOtherRequests(): void - { - $otherRequest = $this->createMock(Request::class); - $handler = new SetLogLevelHandler(); - $this->assertFalse($handler->supports($otherRequest)); - } - - public function testHandleAllLogLevelsAndSupport(): void - { - $handler = new SetLogLevelHandler(); - - foreach (LoggingLevel::cases() as $level) { - $request = $this->createSetLogLevelRequest($level); - - $session = $this->getMockBuilder(Session::class) - ->disableOriginalConstructor() - ->onlyMethods(['set']) - ->getMock(); - $session->expects($this->once()) - ->method('set') - ->with(Protocol::SESSION_LOGGING_LEVEL, $level->value); - - $response = $handler->handle($request, $session); - $this->assertEquals($request->getId(), $response->id); - $this->assertInstanceOf(EmptyResult::class, $response->result); - } - } - - private function createSetLogLevelRequest(LoggingLevel $level): SetLogLevelRequest - { - return SetLogLevelRequest::fromArray([ - 'jsonrpc' => '2.0', - 'method' => SetLogLevelRequest::getMethod(), - 'id' => 'test-request-'.uniqid(), - 'params' => [ - 'level' => $level->value, - ], - ]); - } -} diff --git a/tests/Unit/Server/ProtocolTest.php b/tests/Unit/Server/ProtocolTest.php deleted file mode 100644 index b5f836ea..00000000 --- a/tests/Unit/Server/ProtocolTest.php +++ /dev/null @@ -1,1473 +0,0 @@ - */ - private MockObject&TransportInterface $transport; - - protected function setUp(): void - { - $this->sessionManager = $this->createMock(SessionManagerInterface::class); - $this->transport = $this->createMock(TransportInterface::class); - } - - #[TestDox('A single notification can be handled by multiple handlers')] - public function testNotificationHandledByMultipleHandlers(): void - { - $handlerA = $this->createMock(NotificationHandlerInterface::class); - $handlerA->method('supports')->willReturn(true); - $handlerA->expects($this->once())->method('handle'); - - $handlerB = $this->createMock(NotificationHandlerInterface::class); - $handlerB->method('supports')->willReturn(false); - $handlerB->expects($this->never())->method('handle'); - - $handlerC = $this->createMock(NotificationHandlerInterface::class); - $handlerC->method('supports')->willReturn(true); - $handlerC->expects($this->once())->method('handle'); - - $session = $this->createMock(SessionInterface::class); - - $this->sessionManager->method('createWithId')->willReturn($session); - $this->sessionManager->method('exists')->willReturn(true); - - $protocol = new Protocol( - requestHandlers: [], - notificationHandlers: [$handlerA, $handlerB, $handlerC], - messageFactory: MessageFactory::make(), - sessionManager: $this->sessionManager, - ); - - $sessionId = Uuid::v4(); - $protocol->processInput( - $this->transport, - '{"jsonrpc": "2.0", "method": "notifications/initialized"}', - $sessionId - ); - } - - #[TestDox('A single request is handled only by the first matching handler')] - public function testRequestHandledByFirstMatchingHandler(): void - { - $handlerA = $this->createMock(RequestHandlerInterface::class); - $handlerA->method('supports')->willReturn(true); - $handlerA->expects($this->once())->method('handle')->willReturn(new Response(1, ['result' => 'success'])); - - $handlerB = $this->createMock(RequestHandlerInterface::class); - $handlerB->method('supports')->willReturn(false); - $handlerB->expects($this->never())->method('handle'); - - $handlerC = $this->createMock(RequestHandlerInterface::class); - $handlerC->method('supports')->willReturn(true); - $handlerC->expects($this->never())->method('handle'); - - $session = $this->createMock(SessionInterface::class); - - $this->sessionManager->method('createWithId')->willReturn($session); - $this->sessionManager->method('exists')->willReturn(true); - $session->method('getId')->willReturn(Uuid::v4()); - - // Configure session mock for queue operations - $queue = []; - $session->method('get')->willReturnCallback(static function ($key, $default = null) use (&$queue) { - if ('_mcp.outgoing_queue' === $key) { - return $queue; - } - - return $default; - }); - - $session->method('set')->willReturnCallback(static function ($key, $value) use (&$queue) { - if ('_mcp.outgoing_queue' === $key) { - $queue = $value; - } - }); - - // The protocol now queues responses instead of sending them directly - // save() is called once during processInput and once during consumeOutgoingMessages - $session->expects($this->exactly(2)) - ->method('save'); - - $protocol = new Protocol( - requestHandlers: [$handlerA, $handlerB, $handlerC], - notificationHandlers: [], - messageFactory: MessageFactory::make(), - sessionManager: $this->sessionManager, - ); - - $sessionId = Uuid::v4(); - $protocol->processInput( - $this->transport, - '{"jsonrpc": "2.0", "id": 1, "method": "tools/list"}', - $sessionId - ); - - // Check that the response was queued in the session - $outgoing = $protocol->consumeOutgoingMessages($sessionId); - $this->assertCount(1, $outgoing); - - $message = json_decode($outgoing[0]['message'], true); - $this->assertArrayHasKey('result', $message); - } - - #[TestDox('Initialize request must not have a session ID')] - public function testInitializeRequestWithSessionIdReturnsError(): void - { - $this->transport->expects($this->once()) - ->method('send') - ->with( - $this->callback(static function ($data) { - $decoded = json_decode($data, true); - - return isset($decoded['error']) - && str_contains($decoded['error']['message'], 'session ID MUST NOT be sent'); - }), - $this->anything() - ); - - $protocol = new Protocol( - requestHandlers: [], - notificationHandlers: [], - messageFactory: MessageFactory::make(), - sessionManager: $this->sessionManager, - ); - - $sessionId = Uuid::v4(); - $protocol->processInput( - $this->transport, - '{"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {"protocolVersion": "2024-11-05", "capabilities": {}, "clientInfo": {"name": "test", "version": "1.0"}}}', - $sessionId - ); - } - - #[TestDox('Initialize request must not be part of a batch')] - public function testInitializeRequestInBatchReturnsError(): void - { - $this->transport->expects($this->once()) - ->method('send') - ->with( - $this->callback(static function ($data) { - $decoded = json_decode($data, true); - - return isset($decoded['error']) - && str_contains($decoded['error']['message'], 'MUST NOT be part of a batch'); - }), - $this->anything() - ); - - $protocol = new Protocol( - requestHandlers: [], - notificationHandlers: [], - messageFactory: MessageFactory::make(), - sessionManager: $this->sessionManager, - ); - - $protocol->processInput( - $this->transport, - '[{"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {"protocolVersion": "2024-11-05", "capabilities": {}, "clientInfo": {"name": "test", "version": "1.0"}}}, {"jsonrpc": "2.0", "method": "ping", "id": 2}]', - null - ); - } - - #[TestDox('Non-initialize requests require a session ID')] - public function testNonInitializeRequestWithoutSessionIdReturnsError(): void - { - $this->transport->expects($this->once()) - ->method('send') - ->with( - $this->callback(static function ($data) { - $decoded = json_decode($data, true); - - return isset($decoded['error']) - && str_contains($decoded['error']['message'], 'session id is REQUIRED'); - }), - $this->callback(static function ($context) { - return isset($context['status_code']) && 400 === $context['status_code']; - }) - ); - - $protocol = new Protocol( - requestHandlers: [], - notificationHandlers: [], - messageFactory: MessageFactory::make(), - sessionManager: $this->sessionManager, - ); - - $protocol->processInput( - $this->transport, - '{"jsonrpc": "2.0", "id": 1, "method": "tools/list"}', - null - ); - } - - #[TestDox('Non-existent session ID returns error')] - public function testNonExistentSessionIdReturnsError(): void - { - $this->sessionManager->method('exists')->willReturn(false); - - $this->transport->expects($this->once()) - ->method('send') - ->with( - $this->callback(static function ($data) { - $decoded = json_decode($data, true); - - return isset($decoded['error']) - && str_contains($decoded['error']['message'], 'Session not found or has expired'); - }), - $this->callback(static function ($context) { - return isset($context['status_code']) && 404 === $context['status_code']; - }) - ); - - $protocol = new Protocol( - requestHandlers: [], - notificationHandlers: [], - messageFactory: MessageFactory::make(), - sessionManager: $this->sessionManager, - ); - - $sessionId = Uuid::v4(); - $protocol->processInput( - $this->transport, - '{"jsonrpc": "2.0", "id": 1, "method": "tools/list"}', - $sessionId - ); - } - - #[TestDox('Invalid JSON returns parse error')] - public function testInvalidJsonReturnsParseError(): void - { - $this->transport->expects($this->once()) - ->method('send') - ->with( - $this->callback(static function ($data) { - $decoded = json_decode($data, true); - - return isset($decoded['error']) - && Error::PARSE_ERROR === $decoded['error']['code']; - }), - $this->anything() - ); - - $protocol = new Protocol( - requestHandlers: [], - notificationHandlers: [], - messageFactory: MessageFactory::make(), - sessionManager: $this->sessionManager, - ); - - $protocol->processInput( - $this->transport, - 'invalid json', - null - ); - } - - #[TestDox('Invalid message structure returns error')] - public function testInvalidMessageStructureReturnsError(): void - { - $session = $this->createMock(SessionInterface::class); - - $this->sessionManager->method('createWithId')->willReturn($session); - $this->sessionManager->method('exists')->willReturn(true); - - // Configure session mock for queue operations - $queue = []; - $session->method('get')->willReturnCallback(static function ($key, $default = null) use (&$queue) { - if ('_mcp.outgoing_queue' === $key) { - return $queue; - } - - return $default; - }); - - $session->method('set')->willReturnCallback(static function ($key, $value) use (&$queue) { - if ('_mcp.outgoing_queue' === $key) { - $queue = $value; - } - }); - - // The protocol now queues responses instead of sending them directly - // save() is called once during processInput and once during consumeOutgoingMessages - $session->expects($this->exactly(2)) - ->method('save'); - - $protocol = new Protocol( - requestHandlers: [], - notificationHandlers: [], - messageFactory: MessageFactory::make(), - sessionManager: $this->sessionManager, - ); - - $sessionId = Uuid::v4(); - $protocol->processInput( - $this->transport, - '{"jsonrpc": "2.0", "params": {}}', - $sessionId - ); - - // Check that the error was queued in the session - $outgoing = $protocol->consumeOutgoingMessages($sessionId); - $this->assertCount(1, $outgoing); - - $message = json_decode($outgoing[0]['message'], true); - $this->assertArrayHasKey('error', $message); - $this->assertEquals(Error::INVALID_REQUEST, $message['error']['code']); - } - - #[TestDox('An unexpected throwable while creating a message returns an internal error under its id')] - public function testUnexpectedThrowableWhileCreatingMessagesReturnsInternalError(): void - { - $sent = null; - $this->transport->expects($this->once()) - ->method('send') - ->willReturnCallback(static function ($data) use (&$sent) { - $sent = $data; - }); - - $protocol = new Protocol( - requestHandlers: [], - notificationHandlers: [], - messageFactory: new MessageFactory([ThrowingRequest::class]), - sessionManager: $this->sessionManager, - ); - - $protocol->processInput( - $this->transport, - '{"jsonrpc": "2.0", "id": 1, "method": "test/throwing"}', - Uuid::v4() - ); - - $decoded = json_decode((string) $sent, true); - $this->assertSame(Error::INTERNAL_ERROR, $decoded['error']['code']); - $this->assertSame(1, $decoded['id'], 'The peer must be able to correlate the failure with its request.'); - $this->assertStringNotContainsString('must not leak', $decoded['error']['message']); - } - - #[TestDox('A batch that fails to hydrate is answered once, under the empty id')] - public function testBatchThatFailsToHydrateIsAnsweredOnce(): void - { - $sent = []; - $this->transport->method('send')->willReturnCallback(static function ($data) use (&$sent) { - $sent[] = $data; - }); - - $protocol = new Protocol( - requestHandlers: [], - notificationHandlers: [], - messageFactory: new MessageFactory([PingRequest::class, ThrowingRequest::class]), - sessionManager: $this->sessionManager, - ); - - $protocol->processInput( - $this->transport, - '[{"jsonrpc": "2.0", "id": 1, "method": "ping"}, {"jsonrpc": "2.0", "id": 2, "method": "test/throwing"}]', - Uuid::v4() - ); - - $this->assertCount(1, $sent); - - $decoded = json_decode($sent[0], true); - $this->assertSame(Error::INTERNAL_ERROR, $decoded['error']['code']); - $this->assertSame('', $decoded['id'], 'The failure cannot be attributed to one request of the batch.'); - } - - #[TestDox('A batch holding only notifications is not answered when processing fails')] - public function testBatchOfNotificationsIsNotAnsweredWhenProcessingFails(): void - { - $session = $this->createMock(SessionInterface::class); - $session->method('save')->willThrowException(new \RuntimeException('storage is gone')); - - $this->sessionManager->method('createWithId')->willReturn($session); - $this->sessionManager->method('exists')->willReturn(true); - - $this->transport->expects($this->never())->method('send'); - - $protocol = new Protocol( - requestHandlers: [], - notificationHandlers: [], - messageFactory: MessageFactory::make(), - sessionManager: $this->sessionManager, - ); - - $protocol->processInput( - $this->transport, - '[{"jsonrpc": "2.0", "method": "notifications/initialized"}]', - Uuid::v4() - ); - } - - #[TestDox('An unexpected throwable while saving the session does not answer a notification')] - public function testUnexpectedThrowableWhileSavingSessionDoesNotEscape(): void - { - $session = $this->createMock(SessionInterface::class); - $session->method('save')->willThrowException(new \RuntimeException('storage is gone')); - - $this->sessionManager->method('createWithId')->willReturn($session); - $this->sessionManager->method('exists')->willReturn(true); - - // JSON-RPC forbids answering a notification, so the failure is only logged. - $this->transport->expects($this->never())->method('send'); - - $protocol = new Protocol( - requestHandlers: [], - notificationHandlers: [], - messageFactory: MessageFactory::make(), - sessionManager: $this->sessionManager, - ); - - $protocol->processInput( - $this->transport, - '{"jsonrpc": "2.0", "method": "notifications/initialized"}', - Uuid::v4() - ); - } - - #[TestDox('A failing notification event listener does not produce a response')] - public function testFailingNotificationListenerDoesNotProduceResponse(): void - { - $session = $this->createMock(SessionInterface::class); - - $this->sessionManager->method('createWithId')->willReturn($session); - $this->sessionManager->method('exists')->willReturn(true); - - $queue = []; - $session->method('get')->willReturnCallback(static function ($key, $default = null) use (&$queue) { - return '_mcp.outgoing_queue' === $key ? $queue : $default; - }); - $session->method('set')->willReturnCallback(static function ($key, $value) use (&$queue) { - if ('_mcp.outgoing_queue' === $key) { - $queue = $value; - } - }); - - $dispatcher = $this->createMock(EventDispatcherInterface::class); - $dispatcher->method('dispatch')->willReturnCallback(static function ($event) { - if ($event instanceof NotificationEvent) { - throw new \RuntimeException('listener blew up'); - } - - return $event; - }); - - $this->transport->expects($this->never())->method('send'); - - $protocol = new Protocol( - requestHandlers: [], - notificationHandlers: [], - messageFactory: MessageFactory::make(), - sessionManager: $this->sessionManager, - eventDispatcher: $dispatcher, - ); - - $sessionId = Uuid::v4(); - $protocol->processInput( - $this->transport, - '{"jsonrpc": "2.0", "method": "notifications/initialized"}', - $sessionId - ); - - $this->assertSame([], $protocol->consumeOutgoingMessages($sessionId)); - } - - #[TestDox('Request without handler returns method not found error')] - public function testRequestWithoutHandlerReturnsMethodNotFoundError(): void - { - $session = $this->createMock(SessionInterface::class); - - $this->sessionManager->method('createWithId')->willReturn($session); - $this->sessionManager->method('exists')->willReturn(true); - - // Configure session mock for queue operations - $queue = []; - $session->method('get')->willReturnCallback(static function ($key, $default = null) use (&$queue) { - if ('_mcp.outgoing_queue' === $key) { - return $queue; - } - - return $default; - }); - - $session->method('set')->willReturnCallback(static function ($key, $value) use (&$queue) { - if ('_mcp.outgoing_queue' === $key) { - $queue = $value; - } - }); - - // The protocol now queues responses instead of sending them directly - // save() is called once during processInput and once during consumeOutgoingMessages - $session->expects($this->exactly(2)) - ->method('save'); - - $protocol = new Protocol( - requestHandlers: [], - notificationHandlers: [], - messageFactory: MessageFactory::make(), - sessionManager: $this->sessionManager, - ); - - $sessionId = Uuid::v4(); - $protocol->processInput( - $this->transport, - '{"jsonrpc": "2.0", "id": 1, "method": "ping"}', - $sessionId - ); - - // Check that the error was queued in the session - $outgoing = $protocol->consumeOutgoingMessages($sessionId); - $this->assertCount(1, $outgoing); - - $message = json_decode($outgoing[0]['message'], true); - $this->assertArrayHasKey('error', $message); - $this->assertEquals(Error::METHOD_NOT_FOUND, $message['error']['code']); - $this->assertStringContainsString('No handler found', $message['error']['message']); - } - - #[TestDox('Handler throwing InvalidArgumentException returns invalid params error')] - public function testHandlerInvalidArgumentReturnsInvalidParamsError(): void - { - $handler = $this->createMock(RequestHandlerInterface::class); - $handler->method('supports')->willReturn(true); - $handler->method('handle')->willThrowException(new \InvalidArgumentException('Invalid parameter')); - - $session = $this->createMock(SessionInterface::class); - - $this->sessionManager->method('createWithId')->willReturn($session); - $this->sessionManager->method('exists')->willReturn(true); - - // Configure session mock for queue operations - $queue = []; - $session->method('get')->willReturnCallback(static function ($key, $default = null) use (&$queue) { - if ('_mcp.outgoing_queue' === $key) { - return $queue; - } - - return $default; - }); - - $session->method('set')->willReturnCallback(static function ($key, $value) use (&$queue) { - if ('_mcp.outgoing_queue' === $key) { - $queue = $value; - } - }); - - // The protocol now queues responses instead of sending them directly - // save() is called once during processInput and once during consumeOutgoingMessages - $session->expects($this->exactly(2)) - ->method('save'); - - $protocol = new Protocol( - requestHandlers: [$handler], - notificationHandlers: [], - messageFactory: MessageFactory::make(), - sessionManager: $this->sessionManager, - ); - - $sessionId = Uuid::v4(); - $protocol->processInput( - $this->transport, - '{"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {"name": "test"}}', - $sessionId - ); - - // Check that the error was queued in the session - $outgoing = $protocol->consumeOutgoingMessages($sessionId); - $this->assertCount(1, $outgoing); - - $message = json_decode($outgoing[0]['message'], true); - $this->assertArrayHasKey('error', $message); - $this->assertEquals(Error::INVALID_PARAMS, $message['error']['code']); - $this->assertStringContainsString('Invalid parameter', $message['error']['message']); - } - - #[TestDox('Handler throwing unexpected exception returns internal error')] - public function testHandlerUnexpectedExceptionReturnsInternalError(): void - { - $handler = $this->createMock(RequestHandlerInterface::class); - $handler->method('supports')->willReturn(true); - $handler->method('handle')->willThrowException(new \RuntimeException('Unexpected error')); - - $session = $this->createMock(SessionInterface::class); - - $this->sessionManager->method('createWithId')->willReturn($session); - $this->sessionManager->method('exists')->willReturn(true); - - // Configure session mock for queue operations - $queue = []; - $session->method('get')->willReturnCallback(static function ($key, $default = null) use (&$queue) { - if ('_mcp.outgoing_queue' === $key) { - return $queue; - } - - return $default; - }); - - $session->method('set')->willReturnCallback(static function ($key, $value) use (&$queue) { - if ('_mcp.outgoing_queue' === $key) { - $queue = $value; - } - }); - - // The protocol now queues responses instead of sending them directly - // save() is called once during processInput and once during consumeOutgoingMessages - $session->expects($this->exactly(2)) - ->method('save'); - - $protocol = new Protocol( - requestHandlers: [$handler], - notificationHandlers: [], - messageFactory: MessageFactory::make(), - sessionManager: $this->sessionManager, - ); - - $sessionId = Uuid::v4(); - $protocol->processInput( - $this->transport, - '{"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {"name": "test"}}', - $sessionId - ); - - // Check that the error was queued in the session - $outgoing = $protocol->consumeOutgoingMessages($sessionId); - $this->assertCount(1, $outgoing); - - $message = json_decode($outgoing[0]['message'], true); - $this->assertArrayHasKey('error', $message); - $this->assertEquals(Error::INTERNAL_ERROR, $message['error']['code']); - $this->assertSame('Internal server error.', $message['error']['message']); - $this->assertStringNotContainsString('Unexpected error', $message['error']['message']); - } - - #[TestDox('Notification handler exceptions are caught and logged')] - public function testNotificationHandlerExceptionsAreCaught(): void - { - $handler = $this->createMock(NotificationHandlerInterface::class); - $handler->method('supports')->willReturn(true); - $handler->method('handle')->willThrowException(new \RuntimeException('Handler error')); - - $session = $this->createMock(SessionInterface::class); - - $this->sessionManager->method('createWithId')->willReturn($session); - $this->sessionManager->method('exists')->willReturn(true); - - $protocol = new Protocol( - requestHandlers: [], - notificationHandlers: [$handler], - messageFactory: MessageFactory::make(), - sessionManager: $this->sessionManager, - ); - - $sessionId = Uuid::v4(); - $protocol->processInput( - $this->transport, - '{"jsonrpc": "2.0", "method": "notifications/initialized"}', - $sessionId - ); - - $this->expectNotToPerformAssertions(); - } - - #[TestDox('Successful request returns response with session ID')] - public function testSuccessfulRequestReturnsResponseWithSessionId(): void - { - $handler = $this->createMock(RequestHandlerInterface::class); - $handler->method('supports')->willReturn(true); - $handler->method('handle')->willReturn(new Response(1, ['status' => 'ok'])); - - $sessionId = Uuid::v4(); - $session = $this->createMock(SessionInterface::class); - $session->method('getId')->willReturn($sessionId); - - $this->sessionManager->method('createWithId')->willReturn($session); - $this->sessionManager->method('exists')->willReturn(true); - - // Configure session mock for queue operations - $queue = []; - $session->method('get')->willReturnCallback(static function ($key, $default = null) use (&$queue) { - if ('_mcp.outgoing_queue' === $key) { - return $queue; - } - - return $default; - }); - - $session->method('set')->willReturnCallback(static function ($key, $value) use (&$queue) { - if ('_mcp.outgoing_queue' === $key) { - $queue = $value; - } - }); - - // The protocol now queues responses instead of sending them directly - // save() is called once during processInput and once during consumeOutgoingMessages - $session->expects($this->exactly(2)) - ->method('save'); - - $protocol = new Protocol( - requestHandlers: [$handler], - notificationHandlers: [], - messageFactory: MessageFactory::make(), - sessionManager: $this->sessionManager, - ); - - $protocol->processInput( - $this->transport, - '{"jsonrpc": "2.0", "id": 1, "method": "tools/list"}', - $sessionId - ); - - // Check that the response was queued in the session - $outgoing = $protocol->consumeOutgoingMessages($sessionId); - $this->assertCount(1, $outgoing); - - $message = json_decode($outgoing[0]['message'], true); - $this->assertArrayHasKey('result', $message); - $this->assertEquals(['status' => 'ok'], $message['result']); - } - - #[TestDox('Batch requests are processed and send multiple responses')] - public function testBatchRequestsAreProcessed(): void - { - $handlerA = $this->createMock(RequestHandlerInterface::class); - $handlerA->method('supports')->willReturn(true); - $handlerA->method('handle')->willReturnCallback(static function ($request) { - return Response::fromArray([ - 'jsonrpc' => '2.0', - 'id' => $request->getId(), - 'result' => ['method' => $request::getMethod()], - ]); - }); - - $session = $this->createMock(SessionInterface::class); - $session->method('getId')->willReturn(Uuid::v4()); - - // Configure session mock for queue operations - $queue = []; - $session->method('get')->willReturnCallback(static function ($key, $default = null) use (&$queue) { - if ('_mcp.outgoing_queue' === $key) { - return $queue; - } - - return $default; - }); - - $session->method('set')->willReturnCallback(static function ($key, $value) use (&$queue) { - if ('_mcp.outgoing_queue' === $key) { - $queue = $value; - } - }); - - $this->sessionManager->method('createWithId')->willReturn($session); - $this->sessionManager->method('exists')->willReturn(true); - - // The protocol now queues responses instead of sending them directly - $session->expects($this->exactly(2)) - ->method('save'); - - $protocol = new Protocol( - requestHandlers: [$handlerA], - notificationHandlers: [], - messageFactory: MessageFactory::make(), - sessionManager: $this->sessionManager, - ); - - $sessionId = Uuid::v4(); - $protocol->processInput( - $this->transport, - '[{"jsonrpc": "2.0", "method": "tools/list", "id": 1}, {"jsonrpc": "2.0", "method": "prompts/list", "id": 2}]', - $sessionId - ); - - // Check that both responses were queued in the session - $outgoing = $protocol->consumeOutgoingMessages($sessionId); - $this->assertCount(2, $outgoing); - - foreach ($outgoing as $outgoingMessage) { - $message = json_decode($outgoingMessage['message'], true); - $this->assertArrayHasKey('result', $message); - } - } - - #[TestDox('Session is saved after processing')] - public function testSessionIsSavedAfterProcessing(): void - { - $session = $this->createMock(SessionInterface::class); - - $this->sessionManager->method('createWithId')->willReturn($session); - $this->sessionManager->method('exists')->willReturn(true); - - $session->expects($this->once())->method('save'); - - $protocol = new Protocol( - requestHandlers: [], - notificationHandlers: [], - messageFactory: MessageFactory::make(), - sessionManager: $this->sessionManager, - ); - - $sessionId = Uuid::v4(); - $protocol->processInput( - $this->transport, - '{"jsonrpc": "2.0", "method": "notifications/initialized"}', - $sessionId - ); - } - - #[TestDox('Destroy session removes session from store')] - public function testDestroySessionRemovesSession(): void - { - $sessionId = Uuid::v4(); - - $this->sessionManager->expects($this->once()) - ->method('destroy') - ->with($sessionId); - - $protocol = new Protocol( - requestHandlers: [], - notificationHandlers: [], - messageFactory: MessageFactory::make(), - sessionManager: $this->sessionManager, - ); - - $protocol->destroySession($sessionId); - } - - #[TestDox('RequestEvent is dispatched when a request is received')] - public function testRequestEventIsDispatched(): void - { - $capturedEvents = []; - - $eventDispatcher = $this->createMock(EventDispatcherInterface::class); - $eventDispatcher - ->method('dispatch') - ->willReturnCallback(static function ($event) use (&$capturedEvents) { - $capturedEvents[] = $event; - - return $event; - }); - - $handler = $this->createMock(RequestHandlerInterface::class); - $handler->method('supports')->willReturn(true); - $handler->method('handle')->willReturn(new Response(1, ['result' => 'success'])); - - $session = $this->createMock(SessionInterface::class); - $session->method('getId')->willReturn(Uuid::v4()); - $session->method('get')->willReturn([]); - - $this->sessionManager->method('createWithId')->willReturn($session); - $this->sessionManager->method('exists')->willReturn(true); - - $protocol = new Protocol( - requestHandlers: [$handler], - notificationHandlers: [], - messageFactory: MessageFactory::make(), - sessionManager: $this->sessionManager, - eventDispatcher: $eventDispatcher, - ); - - $sessionId = Uuid::v4(); - $protocol->processInput( - $this->transport, - '{"jsonrpc": "2.0", "method": "ping", "id": 1}', - $sessionId - ); - - // Should have RequestEvent (and ResponseEvent) - $this->assertGreaterThanOrEqual(1, \count($capturedEvents)); - $this->assertInstanceOf(RequestEvent::class, $capturedEvents[0]); - $this->assertSame($session, $capturedEvents[0]->getSession()); - $this->assertSame('ping', $capturedEvents[0]->getMethod()); - } - - #[TestDox('RequestEvent modification is used by handler')] - public function testRequestEventModificationIsUsed(): void - { - $handlerReceivedRequest = null; - - $eventDispatcher = $this->createMock(EventDispatcherInterface::class); - $eventDispatcher - ->method('dispatch') - ->willReturnCallback(static function ($event) { - if ($event instanceof RequestEvent) { - // Simulate a listener modifying the request - $originalRequest = $event->getRequest(); - - // Create a modified CallToolRequest with different name but same ID - $modifiedRequest = CallToolRequest::fromArray([ - 'jsonrpc' => '2.0', - 'id' => $originalRequest->getId(), - 'method' => 'tools/call', - 'params' => [ - 'name' => 'modified_tool', - 'arguments' => ['modified' => true], - ], - ]); - - $event->setRequest($modifiedRequest); - } - - return $event; - }); - - $handler = $this->createMock(RequestHandlerInterface::class); - $handler->method('supports')->willReturn(true); - $handler - ->method('handle') - ->willReturnCallback(static function ($request) use (&$handlerReceivedRequest) { - $handlerReceivedRequest = $request; - - return new Response(1, ['result' => 'success']); - }); - - $session = $this->createMock(SessionInterface::class); - $session->method('getId')->willReturn(Uuid::v4()); - $session->method('get')->willReturn([]); - - $this->sessionManager->method('createWithId')->willReturn($session); - $this->sessionManager->method('exists')->willReturn(true); - - $protocol = new Protocol( - requestHandlers: [$handler], - notificationHandlers: [], - messageFactory: MessageFactory::make(), - sessionManager: $this->sessionManager, - eventDispatcher: $eventDispatcher, - ); - - $sessionId = Uuid::v4(); - $protocol->processInput( - $this->transport, - '{"jsonrpc": "2.0", "method": "tools/call", "id": 1, "params": {"name": "original_tool", "arguments": {}}}', - $sessionId - ); - - // Verify the handler received the modified request - $this->assertInstanceOf(CallToolRequest::class, $handlerReceivedRequest); - - $this->assertSame('modified_tool', $handlerReceivedRequest->name); - $this->assertSame(['modified' => true], $handlerReceivedRequest->arguments); - } - - #[TestDox('RequestEvent works with null EventDispatcher')] - public function testRequestEventWithNullDispatcher(): void - { - $handler = $this->createMock(RequestHandlerInterface::class); - $handler->method('supports')->willReturn(true); - $handler->method('handle')->willReturn(new Response(1, ['result' => 'success'])); - - $session = $this->createMock(SessionInterface::class); - $session->method('getId')->willReturn(Uuid::v4()); - $session->method('get')->willReturn([]); - - $this->sessionManager->method('createWithId')->willReturn($session); - $this->sessionManager->method('exists')->willReturn(true); - - $protocol = new Protocol( - requestHandlers: [$handler], - notificationHandlers: [], - messageFactory: MessageFactory::make(), - sessionManager: $this->sessionManager, - eventDispatcher: null, - ); - - $sessionId = Uuid::v4(); - $protocol->processInput( - $this->transport, - '{"jsonrpc": "2.0", "method": "ping", "id": 1}', - $sessionId - ); - - // Should not crash - success - $this->expectNotToPerformAssertions(); - } - - #[TestDox('ResponseEvent is dispatched when handler returns Response')] - public function testResponseEventIsDispatched(): void - { - $capturedEvents = []; - - $eventDispatcher = $this->createMock(EventDispatcherInterface::class); - $eventDispatcher - ->method('dispatch') - ->willReturnCallback(static function ($event) use (&$capturedEvents) { - $capturedEvents[] = $event; - - return $event; - }); - - $handler = $this->createMock(RequestHandlerInterface::class); - $handler->method('supports')->willReturn(true); - $handler->method('handle')->willReturn(new Response(1, ['result' => 'success'])); - - $session = $this->createMock(SessionInterface::class); - $session->method('getId')->willReturn(Uuid::v4()); - $session->method('get')->willReturn([]); - - $this->sessionManager->method('createWithId')->willReturn($session); - $this->sessionManager->method('exists')->willReturn(true); - - $protocol = new Protocol( - requestHandlers: [$handler], - notificationHandlers: [], - messageFactory: MessageFactory::make(), - sessionManager: $this->sessionManager, - eventDispatcher: $eventDispatcher, - ); - - $sessionId = Uuid::v4(); - $protocol->processInput( - $this->transport, - '{"jsonrpc": "2.0", "method": "ping", "id": 1}', - $sessionId - ); - - // Should have RequestEvent and ResponseEvent - $this->assertCount(2, $capturedEvents); - $this->assertInstanceOf(RequestEvent::class, $capturedEvents[0]); - $this->assertInstanceOf(ResponseEvent::class, $capturedEvents[1]); - - /** @var ResponseEvent $responseEvent */ - $responseEvent = $capturedEvents[1]; - $this->assertSame($session, $responseEvent->getSession()); - $this->assertSame('ping', $responseEvent->getMethod()); - $this->assertInstanceOf(Response::class, $responseEvent->getResponse()); - } - - #[TestDox('ResponseEvent modification is used when sending')] - public function testResponseEventModificationIsUsed(): void - { - $outgoingQueue = []; - - $eventDispatcher = $this->createMock(EventDispatcherInterface::class); - $eventDispatcher - ->method('dispatch') - ->willReturnCallback(static function ($event) { - if ($event instanceof ResponseEvent) { - // Simulate a listener modifying the response - $modifiedResponse = new Response( - $event->getResponse()->getId(), - ['result' => 'modified', 'original' => false] - ); - $event->setResponse($modifiedResponse); - } - - return $event; - }); - - $handler = $this->createMock(RequestHandlerInterface::class); - $handler->method('supports')->willReturn(true); - $handler->method('handle')->willReturn(new Response(1, ['result' => 'original'])); - - $session = $this->createMock(SessionInterface::class); - $session->method('getId')->willReturn(Uuid::v4()); - $session->method('get')->willReturn([]); - $session - ->method('set') - ->willReturnCallback(static function ($key, $value) use (&$outgoingQueue) { - if ('_mcp.outgoing_queue' === $key) { - $outgoingQueue = $value; - } - }); - - $this->sessionManager->method('createWithId')->willReturn($session); - $this->sessionManager->method('exists')->willReturn(true); - - $protocol = new Protocol( - requestHandlers: [$handler], - notificationHandlers: [], - messageFactory: MessageFactory::make(), - sessionManager: $this->sessionManager, - eventDispatcher: $eventDispatcher, - ); - - $sessionId = Uuid::v4(); - $protocol->processInput( - $this->transport, - '{"jsonrpc": "2.0", "method": "ping", "id": 1}', - $sessionId - ); - - // Verify the MODIFIED response was queued - $this->assertNotEmpty($outgoingQueue); - $lastQueued = end($outgoingQueue); - $this->assertIsArray($lastQueued); - $this->assertArrayHasKey('message', $lastQueued); - - $decoded = json_decode($lastQueued['message'], true); - $this->assertSame('modified', $decoded['result']['result']); - $this->assertFalse($decoded['result']['original']); - } - - #[TestDox('ErrorEvent is dispatched when handler returns Error')] - public function testErrorEventIsDispatchedForErrorResult(): void - { - $capturedEvents = []; - - $eventDispatcher = $this->createMock(EventDispatcherInterface::class); - $eventDispatcher - ->method('dispatch') - ->willReturnCallback(static function ($event) use (&$capturedEvents) { - $capturedEvents[] = $event; - - return $event; - }); - - $handler = $this->createMock(RequestHandlerInterface::class); - $handler->method('supports')->willReturn(true); - $handler->method('handle')->willReturn(Error::forInternalError('test error', 1)); - - $session = $this->createMock(SessionInterface::class); - $session->method('getId')->willReturn(Uuid::v4()); - $session->method('get')->willReturn([]); - - $this->sessionManager->method('createWithId')->willReturn($session); - $this->sessionManager->method('exists')->willReturn(true); - - $protocol = new Protocol( - requestHandlers: [$handler], - notificationHandlers: [], - messageFactory: MessageFactory::make(), - sessionManager: $this->sessionManager, - eventDispatcher: $eventDispatcher, - ); - - $sessionId = Uuid::v4(); - $protocol->processInput( - $this->transport, - '{"jsonrpc": "2.0", "method": "ping", "id": 1}', - $sessionId - ); - - // Should have RequestEvent and ErrorEvent - $this->assertCount(2, $capturedEvents); - $this->assertInstanceOf(RequestEvent::class, $capturedEvents[0]); - $this->assertInstanceOf(ErrorEvent::class, $capturedEvents[1]); - - /** @var ErrorEvent $errorEvent */ - $errorEvent = $capturedEvents[1]; - $this->assertSame($session, $errorEvent->getSession()); - $this->assertNull($errorEvent->getThrowable()); - $this->assertInstanceOf(Error::class, $errorEvent->getError()); - } - - #[TestDox('ErrorEvent is dispatched on InvalidArgumentException')] - public function testErrorEventIsDispatchedForInvalidArgument(): void - { - $capturedEvents = []; - - $eventDispatcher = $this->createMock(EventDispatcherInterface::class); - $eventDispatcher - ->method('dispatch') - ->willReturnCallback(static function ($event) use (&$capturedEvents) { - $capturedEvents[] = $event; - - return $event; - }); - - $handler = $this->createMock(RequestHandlerInterface::class); - $handler->method('supports')->willReturn(true); - $handler->method('handle')->willThrowException(new \InvalidArgumentException('Invalid param')); - - $session = $this->createMock(SessionInterface::class); - $session->method('getId')->willReturn(Uuid::v4()); - $session->method('get')->willReturn([]); - - $this->sessionManager->method('createWithId')->willReturn($session); - $this->sessionManager->method('exists')->willReturn(true); - - $protocol = new Protocol( - requestHandlers: [$handler], - notificationHandlers: [], - messageFactory: MessageFactory::make(), - sessionManager: $this->sessionManager, - eventDispatcher: $eventDispatcher, - ); - - $sessionId = Uuid::v4(); - $protocol->processInput( - $this->transport, - '{"jsonrpc": "2.0", "method": "ping", "id": 1}', - $sessionId - ); - - // Should have RequestEvent and ErrorEvent - $this->assertCount(2, $capturedEvents); - $this->assertInstanceOf(RequestEvent::class, $capturedEvents[0]); - $this->assertInstanceOf(ErrorEvent::class, $capturedEvents[1]); - - /** @var ErrorEvent $errorEvent */ - $errorEvent = $capturedEvents[1]; - $this->assertInstanceOf(\InvalidArgumentException::class, $errorEvent->getThrowable()); - $this->assertSame('Invalid param', $errorEvent->getThrowable()->getMessage()); - } - - #[TestDox('ErrorEvent is dispatched on generic Throwable')] - public function testErrorEventIsDispatchedForGenericException(): void - { - $capturedEvents = []; - - $eventDispatcher = $this->createMock(EventDispatcherInterface::class); - $eventDispatcher - ->method('dispatch') - ->willReturnCallback(static function ($event) use (&$capturedEvents) { - $capturedEvents[] = $event; - - return $event; - }); - - $handler = $this->createMock(RequestHandlerInterface::class); - $handler->method('supports')->willReturn(true); - $handler->method('handle')->willThrowException(new \RuntimeException('Runtime error')); - - $session = $this->createMock(SessionInterface::class); - $session->method('getId')->willReturn(Uuid::v4()); - $session->method('get')->willReturn([]); - - $this->sessionManager->method('createWithId')->willReturn($session); - $this->sessionManager->method('exists')->willReturn(true); - - $protocol = new Protocol( - requestHandlers: [$handler], - notificationHandlers: [], - messageFactory: MessageFactory::make(), - sessionManager: $this->sessionManager, - eventDispatcher: $eventDispatcher, - ); - - $sessionId = Uuid::v4(); - $protocol->processInput( - $this->transport, - '{"jsonrpc": "2.0", "method": "ping", "id": 1}', - $sessionId - ); - - // Should have RequestEvent and ErrorEvent - $this->assertCount(2, $capturedEvents); - $this->assertInstanceOf(RequestEvent::class, $capturedEvents[0]); - $this->assertInstanceOf(ErrorEvent::class, $capturedEvents[1]); - - /** @var ErrorEvent $errorEvent */ - $errorEvent = $capturedEvents[1]; - $this->assertInstanceOf(\RuntimeException::class, $errorEvent->getThrowable()); - $this->assertSame('Runtime error', $errorEvent->getThrowable()->getMessage()); - } - - #[TestDox('ErrorEvent is dispatched when no handler found')] - public function testErrorEventIsDispatchedForMethodNotFound(): void - { - $capturedEvents = []; - - $eventDispatcher = $this->createMock(EventDispatcherInterface::class); - $eventDispatcher - ->method('dispatch') - ->willReturnCallback(static function ($event) use (&$capturedEvents) { - $capturedEvents[] = $event; - - return $event; - }); - - $session = $this->createMock(SessionInterface::class); - $session->method('getId')->willReturn(Uuid::v4()); - $session->method('get')->willReturn([]); - $session->expects($this->once())->method('save'); - $session->expects($this->atLeastOnce())->method('set'); - - $this->sessionManager->method('create')->willReturn($session); // create() for initialize - $this->sessionManager->method('exists')->willReturn(false); // No existing session - - $protocol = new Protocol( - requestHandlers: [], // No handlers - notificationHandlers: [], - messageFactory: MessageFactory::make(), - sessionManager: $this->sessionManager, - eventDispatcher: $eventDispatcher, - ); - - $protocol->processInput( - $this->transport, - '{"jsonrpc": "2.0", "method": "initialize", "id": 1, "params": {"protocolVersion": "2024-11-05", "capabilities": {}, "clientInfo": {"name": "test", "version": "1.0"}}}', - null // Initialize must not have sessionId - ); - - // Should have RequestEvent and ErrorEvent - $this->assertCount(2, $capturedEvents); - $this->assertInstanceOf(RequestEvent::class, $capturedEvents[0]); - $this->assertInstanceOf(ErrorEvent::class, $capturedEvents[1]); - - /** @var ErrorEvent $errorEvent */ - $errorEvent = $capturedEvents[1]; - $this->assertNull($errorEvent->getThrowable()); - $this->assertInstanceOf(Error::class, $errorEvent->getError()); - } - - #[TestDox('NotificationEvent is dispatched when notification received')] - public function testNotificationEventIsDispatched(): void - { - $capturedEvent = null; - - $eventDispatcher = $this->createMock(EventDispatcherInterface::class); - $eventDispatcher - ->expects($this->once()) - ->method('dispatch') - ->with($this->callback(static function ($event) use (&$capturedEvent) { - $capturedEvent = $event; - - return $event instanceof NotificationEvent; - })) - ->willReturnArgument(0); - - $handler = $this->createMock(NotificationHandlerInterface::class); - $handler->method('supports')->willReturn(true); - $handler->expects($this->once())->method('handle'); - - $session = $this->createMock(SessionInterface::class); - - $this->sessionManager->method('createWithId')->willReturn($session); - $this->sessionManager->method('exists')->willReturn(true); - - $protocol = new Protocol( - requestHandlers: [], - notificationHandlers: [$handler], - messageFactory: MessageFactory::make(), - sessionManager: $this->sessionManager, - eventDispatcher: $eventDispatcher, - ); - - $sessionId = Uuid::v4(); - $protocol->processInput( - $this->transport, - '{"jsonrpc": "2.0", "method": "notifications/initialized"}', - $sessionId - ); - - $this->assertNotNull($capturedEvent); - $this->assertInstanceOf(NotificationEvent::class, $capturedEvent); - $this->assertSame($session, $capturedEvent->getSession()); - $this->assertSame('notifications/initialized', $capturedEvent->getMethod()); - } - - #[TestDox('NotificationEvent modification is used by handlers')] - public function testNotificationEventModificationIsUsed(): void - { - $handlerReceivedNotification = null; - - $eventDispatcher = $this->createMock(EventDispatcherInterface::class); - $eventDispatcher - ->method('dispatch') - ->willReturnCallback(static function ($event) { - if ($event instanceof NotificationEvent) { - // Simulate a listener modifying the notification - $modifiedNotification = LoggingMessageNotification::fromArray([ - 'jsonrpc' => '2.0', - 'method' => 'notifications/message', - 'params' => [ - 'level' => 'error', - 'data' => 'modified message', - 'logger' => 'modified_logger', - ], - ]); - - $event->setNotification($modifiedNotification); - } - - return $event; - }); - - $handler = $this->createMock(NotificationHandlerInterface::class); - $handler->method('supports')->willReturn(true); - $handler - ->method('handle') - ->willReturnCallback(static function ($notification) use (&$handlerReceivedNotification) { - $handlerReceivedNotification = $notification; - }); - - $session = $this->createMock(SessionInterface::class); - - $this->sessionManager->method('createWithId')->willReturn($session); - $this->sessionManager->method('exists')->willReturn(true); - - $protocol = new Protocol( - requestHandlers: [], - notificationHandlers: [$handler], - messageFactory: MessageFactory::make(), - sessionManager: $this->sessionManager, - eventDispatcher: $eventDispatcher, - ); - - $sessionId = Uuid::v4(); - $protocol->processInput( - $this->transport, - '{"jsonrpc": "2.0", "method": "notifications/message", "params": {"level": "info", "data": "original message"}}', - $sessionId - ); - - // Verify the handler received the MODIFIED notification - $this->assertInstanceOf(LoggingMessageNotification::class, $handlerReceivedNotification); - $this->assertSame(LoggingLevel::Error, $handlerReceivedNotification->level); - $this->assertSame('modified message', $handlerReceivedNotification->data); - $this->assertSame('modified_logger', $handlerReceivedNotification->logger); - } - - #[TestDox('NotificationEvent works with null EventDispatcher')] - public function testNotificationEventWithNullDispatcher(): void - { - $handler = $this->createMock(NotificationHandlerInterface::class); - $handler->method('supports')->willReturn(true); - $handler->expects($this->once())->method('handle'); - - $session = $this->createMock(SessionInterface::class); - - $this->sessionManager->method('createWithId')->willReturn($session); - $this->sessionManager->method('exists')->willReturn(true); - - $protocol = new Protocol( - requestHandlers: [], - notificationHandlers: [$handler], - messageFactory: MessageFactory::make(), - sessionManager: $this->sessionManager, - eventDispatcher: null, - ); - - $sessionId = Uuid::v4(); - $protocol->processInput( - $this->transport, - '{"jsonrpc": "2.0", "method": "notifications/initialized"}', - $sessionId - ); - } -} diff --git a/tests/Unit/Server/RequestContextTest.php b/tests/Unit/Server/RequestContextTest.php deleted file mode 100644 index fcbe65d4..00000000 --- a/tests/Unit/Server/RequestContextTest.php +++ /dev/null @@ -1,89 +0,0 @@ -createSession('2025-06-18'), - $this->createRequest(), - ); - - $this->assertSame(ProtocolVersion::V2025_06_18, $context->getProtocolVersion()); - } - - public function testPerRequestMetaTakesPrecedenceOverTheSession(): void - { - // Modern revisions have no `initialize`, so the revision travels with every - // single request instead of being negotiated once. - $context = new RequestContext( - $this->createSession('2025-11-25'), - $this->createRequest(['io.modelcontextprotocol/protocolVersion' => '2026-07-28']), - ); - - $this->assertSame(ProtocolVersion::V2026_07_28, $context->getProtocolVersion()); - } - - /** - * @dataProvider provideUnusableVersions - */ - public function testUnusableVersionFallsBackToTheNewestHandshakeRevision(mixed $stored): void - { - $context = new RequestContext( - $this->createSession($stored), - $this->createRequest(), - ); - - $this->assertSame(ProtocolVersion::latestHandshake(), $context->getProtocolVersion()); - } - - /** - * @return iterable - */ - public static function provideUnusableVersions(): iterable - { - yield 'never negotiated' => [null]; - yield 'unknown revision' => ['1999-01-01']; - yield 'not a string' => [20260728]; - } - - private function createSession(mixed $protocolVersion): SessionInterface - { - $session = $this->createMock(SessionInterface::class); - $session->method('get')->with('protocol_version')->willReturn($protocolVersion); - - return $session; - } - - /** - * @param array|null $meta - */ - private function createRequest(?array $meta = null): CallToolRequest - { - $request = CallToolRequest::fromArray([ - 'jsonrpc' => '2.0', - 'method' => CallToolRequest::getMethod(), - 'id' => 'test-request', - 'params' => ['name' => 'test_tool', 'arguments' => []], - ]); - - return null === $meta ? $request : $request->withMeta($meta); - } -} diff --git a/tests/Unit/Server/Session/FileSessionStoreTest.php b/tests/Unit/Server/Session/FileSessionStoreTest.php deleted file mode 100644 index 1e0caa75..00000000 --- a/tests/Unit/Server/Session/FileSessionStoreTest.php +++ /dev/null @@ -1,90 +0,0 @@ -directory = sys_get_temp_dir().'/mcp-file-session-store-'.bin2hex(random_bytes(6)); - } - - protected function tearDown(): void - { - if (!is_dir($this->directory)) { - return; - } - - @chmod($this->directory, 0775); - - foreach (glob($this->directory.'/*') ?: [] as $file) { - @unlink($file); - } - - @rmdir($this->directory); - } - - #[TestDox('creates the session directory when it does not exist yet')] - public function testCreatesMissingDirectory(): void - { - new FileSessionStore($this->directory); - - $this->assertDirectoryExists($this->directory); - } - - #[TestDox('round-trips a session payload through the filesystem')] - public function testWriteThenRead(): void - { - $store = new FileSessionStore($this->directory); - $id = new UuidV4(); - - $store->write($id, 'payload'); - - $this->assertTrue($store->exists($id)); - $this->assertSame('payload', $store->read($id)); - } - - #[TestDox('rejects an unwritable directory with the SDK\'s own exception')] - public function testUnwritableDirectoryThrowsPackageException(): void - { - mkdir($this->directory, 0775, true); - chmod($this->directory, 0555); - clearstatcache(true, $this->directory); - - if (is_writable($this->directory)) { - $this->markTestSkipped('Permission bits do not restrict writes here (running as root, or a filesystem that ignores them).'); - } - - // The store's only throw must stay inside the package hierarchy, so a - // consumer catching ExceptionInterface sees it rather than a bare SPL - // RuntimeException escaping the SDK. - $this->expectException(RuntimeException::class); - $this->expectExceptionMessage(\sprintf('Session directory "%s" is not writable.', $this->directory)); - - try { - new FileSessionStore($this->directory); - } catch (RuntimeException $e) { - $this->assertInstanceOf(ExceptionInterface::class, $e); - - throw $e; - } - } -} diff --git a/tests/Unit/Server/Session/SessionManagerTest.php b/tests/Unit/Server/Session/SessionManagerTest.php deleted file mode 100644 index e145292b..00000000 --- a/tests/Unit/Server/Session/SessionManagerTest.php +++ /dev/null @@ -1,80 +0,0 @@ -createMock(InMemorySessionStore::class); - $store->expects($this->never())->method('gc'); - - $manager = new SessionManager($store, gcProbability: 0); - - // Call gc many times — it should never trigger - for ($i = 0; $i < 100; ++$i) { - $manager->gc(); - } - } - - public function testGcAlwaysRunsWhenProbabilityEqualsDivisor(): void - { - $store = $this->createMock(InMemorySessionStore::class); - $store->expects($this->exactly(10))->method('gc')->willReturn([]); - - $manager = new SessionManager($store, gcProbability: 1, gcDivisor: 1); - - for ($i = 0; $i < 10; ++$i) { - $manager->gc(); - } - } - - public function testGcAlwaysRunsWhenProbabilityExceedsDivisor(): void - { - $store = $this->createMock(InMemorySessionStore::class); - $store->expects($this->exactly(5))->method('gc')->willReturn([]); - - $manager = new SessionManager($store, gcProbability: 100, gcDivisor: 1); - - for ($i = 0; $i < 5; ++$i) { - $manager->gc(); - } - } - - public function testGcProbabilityMustBeNonNegative(): void - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('gcProbability must be greater than or equal to 0.'); - - new SessionManager(new InMemorySessionStore(), gcProbability: -1); - } - - public function testGcDivisorMustBePositive(): void - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('gcDivisor must be greater than or equal to 1.'); - - new SessionManager(new InMemorySessionStore(), gcDivisor: 0); - } - - public function testDefaultGcConfiguration(): void - { - // Default should be 1/100 — just verify construction works - $manager = new SessionManager(new InMemorySessionStore()); - $this->assertInstanceOf(SessionManager::class, $manager); - } -} diff --git a/tests/Unit/Server/Session/SessionTest.php b/tests/Unit/Server/Session/SessionTest.php deleted file mode 100644 index 40483d3d..00000000 --- a/tests/Unit/Server/Session/SessionTest.php +++ /dev/null @@ -1,314 +0,0 @@ -store = new InMemorySessionStore(); - $this->session = new Session($this->store); - } - - public function testGetIdReturnsSessionId(): void - { - $id = new UuidV4(); - $session = new Session($this->store, $id); - - $this->assertSame($id, $session->getId()); - } - - public function testSetAndGetSimpleKey(): void - { - $this->session->set('foo', 'bar'); - - $this->assertSame('bar', $this->session->get('foo')); - } - - public function testGetReturnsDefaultWhenKeyDoesNotExist(): void - { - $this->assertNull($this->session->get('nonexistent')); - $this->assertSame('default', $this->session->get('nonexistent', 'default')); - } - - public function testSetAndGetNestedKey(): void - { - $this->session->set('user.name', 'John'); - $this->session->set('user.email', 'john@example.com'); - $this->session->set('user.address.city', 'New York'); - - $this->assertSame('John', $this->session->get('user.name')); - $this->assertSame('john@example.com', $this->session->get('user.email')); - $this->assertSame('New York', $this->session->get('user.address.city')); - $this->assertSame(['city' => 'New York'], $this->session->get('user.address')); - } - - public function testSetDoesNotOverwriteWhenOverwriteIsFalse(): void - { - $this->session->set('key', 'original'); - $this->session->set('key', 'new', overwrite: false); - - $this->assertSame('original', $this->session->get('key')); - } - - public function testSetOverwritesWhenOverwriteIsTrue(): void - { - $this->session->set('key', 'original'); - $this->session->set('key', 'new'); - - $this->assertSame('new', $this->session->get('key')); - } - - public function testHasReturnsTrueForExistingKey(): void - { - $this->session->set('foo', 'bar'); - - $this->assertTrue($this->session->has('foo')); - } - - public function testHasReturnsFalseForNonExistingKey(): void - { - $this->assertFalse($this->session->has('nonexistent')); - } - - public function testHasWorksWithNestedKeys(): void - { - $this->session->set('user.name', 'John'); - - $this->assertTrue($this->session->has('user')); - $this->assertTrue($this->session->has('user.name')); - $this->assertFalse($this->session->has('user.email')); - $this->assertFalse($this->session->has('user.name.first')); - } - - public function testForgetRemovesSimpleKey(): void - { - $this->session->set('foo', 'bar'); - $this->session->forget('foo'); - - $this->assertFalse($this->session->has('foo')); - $this->assertNull($this->session->get('foo')); - } - - public function testForgetRemovesNestedKey(): void - { - $this->session->set('user.name', 'John'); - $this->session->set('user.email', 'john@example.com'); - $this->session->forget('user.name'); - - $this->assertFalse($this->session->has('user.name')); - $this->assertTrue($this->session->has('user.email')); - } - - public function testClearRemovesAllData(): void - { - $this->session->set('foo', 'bar'); - $this->session->set('baz', 'qux'); - $this->session->clear(); - - $this->assertSame([], $this->session->all()); - $this->assertFalse($this->session->has('foo')); - $this->assertFalse($this->session->has('baz')); - } - - public function testPullReturnsValueAndRemovesKey(): void - { - $this->session->set('foo', 'bar'); - - $value = $this->session->pull('foo'); - - $this->assertSame('bar', $value); - $this->assertFalse($this->session->has('foo')); - } - - public function testPullReturnsDefaultWhenKeyDoesNotExist(): void - { - $this->assertNull($this->session->pull('nonexistent')); - $this->assertSame('default', $this->session->pull('also_nonexistent', 'default')); - } - - public function testAllReturnsAllData(): void - { - $this->session->set('foo', 'bar'); - $this->session->set('user.name', 'John'); - - $all = $this->session->all(); - - $this->assertSame([ - 'foo' => 'bar', - 'user' => ['name' => 'John'], - ], $all); - } - - public function testHydrateReplacesAllData(): void - { - $this->session->set('original', 'value'); - - $this->session->hydrate(['new' => 'data', 'nested' => ['key' => 'value']]); - - $this->assertSame([ - 'new' => 'data', - 'nested' => ['key' => 'value'], - ], $this->session->all()); - $this->assertFalse($this->session->has('original')); - } - - public function testJsonSerializeReturnsAllData(): void - { - $this->session->set('foo', 'bar'); - $this->session->set('user.name', 'John'); - - $serialized = $this->session->jsonSerialize(); - - $this->assertSame([ - 'foo' => 'bar', - 'user' => ['name' => 'John'], - ], $serialized); - } - - public function testSavePersistsDataToStore(): void - { - $this->session->set('foo', 'bar'); - $result = $this->session->save(); - - $this->assertTrue($result); - - // Verify data was persisted by creating a new session with the same ID - $newSession = new Session($this->store, $this->session->getId()); - $this->assertSame('bar', $newSession->get('foo')); - } - - public function testSessionLoadsDataFromStoreOnConstruction(): void - { - // Set and save data in one session - $this->session->set('persisted', 'value'); - $this->session->save(); - $sessionId = $this->session->getId(); - - // Create a new session instance with the same ID - $newSession = new Session($this->store, $sessionId); - - $this->assertSame('value', $newSession->get('persisted')); - } - - public function testSetCreatesNestedStructure(): void - { - $this->session->set('a.b.c.d', 'value'); - - $this->assertSame('value', $this->session->get('a.b.c.d')); - $this->assertSame(['d' => 'value'], $this->session->get('a.b.c')); - $this->assertSame(['c' => ['d' => 'value']], $this->session->get('a.b')); - $this->assertSame(['b' => ['c' => ['d' => 'value']]], $this->session->get('a')); - } - - public function testSetOverwritesNonArrayWithNestedStructure(): void - { - $this->session->set('key', 'string_value'); - $this->session->set('key.nested', 'nested_value'); - - $this->assertSame('nested_value', $this->session->get('key.nested')); - $this->assertSame(['nested' => 'nested_value'], $this->session->get('key')); - } - - public function testGetReturnsArrayForIntermediateKey(): void - { - $this->session->set('user.profile.name', 'John'); - $this->session->set('user.profile.age', 30); - - $profile = $this->session->get('user.profile'); - - $this->assertSame(['name' => 'John', 'age' => 30], $profile); - } - - public function testForgetDoesNotThrowWhenKeyDoesNotExist(): void - { - $this->session->forget('nonexistent'); - $this->session->forget('nested.nonexistent'); - - $this->assertFalse($this->session->has('nonexistent')); - } - - public function testSessionCanStoreVariousDataTypes(): void - { - $this->session->set('string', 'value'); - $this->session->set('int', 42); - $this->session->set('float', 3.14); - $this->session->set('bool', true); - $this->session->set('null', null); - $this->session->set('array', ['a', 'b', 'c']); - $this->session->set('assoc', ['key' => 'value']); - - $this->assertSame('value', $this->session->get('string')); - $this->assertSame(42, $this->session->get('int')); - $this->assertSame(3.14, $this->session->get('float')); - $this->assertTrue($this->session->get('bool')); - $this->assertNull($this->session->get('null')); - $this->assertSame(['a', 'b', 'c'], $this->session->get('array')); - $this->assertSame(['key' => 'value'], $this->session->get('assoc')); - } - - public function testSessionGeneratesUniqueIdIfNotProvided(): void - { - $session1 = new Session($this->store); - $session2 = new Session($this->store); - - $this->assertNotEquals($session1->getId()->toRfc4122(), $session2->getId()->toRfc4122()); - } - - public function testAll(): void - { - $store = $this->getMockBuilder(InMemorySessionStore::class) - ->disableOriginalConstructor() - ->onlyMethods(['read']) - ->getMock(); - $store->expects($this->once())->method('read')->willReturn(json_encode(['foo' => 'bar'])); - - $session = new Session($store); - $result = $session->all(); - $this->assertEquals(['foo' => 'bar'], $result); - - // Call again to make sure we dont read from Store - $result = $session->all(); - $this->assertEquals(['foo' => 'bar'], $result); - } - - public function testSaveBeforeReadInitializesData(): void - { - $store = new InMemorySessionStore(); - $session = new Session($store); - - // save() before any get()/set() should not crash - $this->assertTrue($session->save()); - } - - public function testAllReturnsEmptyArrayForNullPayload(): void - { - $store = $this->getMockBuilder(InMemorySessionStore::class) - ->disableOriginalConstructor() - ->onlyMethods(['read']) - ->getMock(); - $store->expects($this->once())->method('read')->willReturn('null'); - - $session = new Session($store); - $result = $session->all(); - - $this->assertSame([], $result); - } -} diff --git a/tests/Unit/Server/SessionSubscriptionManagerTest.php b/tests/Unit/Server/SessionSubscriptionManagerTest.php deleted file mode 100644 index e0fd2847..00000000 --- a/tests/Unit/Server/SessionSubscriptionManagerTest.php +++ /dev/null @@ -1,176 +0,0 @@ -logger = $this->createMock(LoggerInterface::class); - $this->protocol = $this->createMock(Protocol::class); - $this->subscriptionManager = new SessionSubscriptionManager($this->logger); - } - - #[TestDox('Subscribing to a resource sends update notifications')] - public function testSubscribeAndSendsNotification(): void - { - // Arrange - $session = $this->createMock(SessionInterface::class); - $session->method('getId')->willReturn(Uuid::v4()); - $uri = 'test://resource'; - - $session->method('get') - ->with('resource_subscriptions', []) - ->willReturnOnConsecutiveCalls( - [], - [$uri => true] - ); - - $session->expects($this->once())->method('set')->with('resource_subscriptions', [$uri => true]); - $session->expects($this->once())->method('save'); - - // Act - $this->subscriptionManager->subscribe($session, $uri); - - // Assert - $this->protocol->expects($this->once()) - ->method('sendNotification') - ->with($this->isInstanceOf(ResourceUpdatedNotification::class)); - - $this->subscriptionManager->notifyResourceChanged($this->protocol, $session, $uri); - } - - #[TestDox('Unsubscribe from a resource')] - public function testUnsubscribeFromAResource(): void - { - // Arrange - $session = $this->createMock(SessionInterface::class); - $session->method('getId')->willReturn(Uuid::v4()); - $uri = 'test://resource'; - - $session->method('get') - ->with('resource_subscriptions', []) - ->willReturnOnConsecutiveCalls( - [], - [$uri => true], - [$uri => true], - ); - - $session->expects($this->exactly(2))->method('set'); - $session->expects($this->exactly(2))->method('save'); - - // Act - $this->subscriptionManager->subscribe($session, $uri); - - $this->protocol->expects($this->once())->method('sendNotification'); - $this->subscriptionManager->notifyResourceChanged($this->protocol, $session, $uri); - - $this->subscriptionManager->unsubscribe($session, $uri); - } - - #[TestDox('Unsubscribing from a resource verifies that no notification is sent')] - public function testUnsubscribeDoesNotSendNotifications(): void - { - // Arrange - $protocol = $this->createMock(Protocol::class); - $session = $this->createMock(SessionInterface::class); - $session->method('getId')->willReturn(Uuid::v4()); - $uri = 'test://resource'; - - $session->method('get') - ->with('resource_subscriptions', []) - ->willReturnOnConsecutiveCalls( - [], - [$uri => true], - [] - ); - - $session->expects($this->exactly(2))->method('set'); - $session->expects($this->exactly(2))->method('save'); - - // Act - $this->subscriptionManager->subscribe($session, $uri); - $this->subscriptionManager->unsubscribe($session, $uri); - - // Assert - $protocol->expects($this->never())->method('sendNotification'); - $this->subscriptionManager->notifyResourceChanged($protocol, $session, $uri); - } - - #[TestDox('Logs error when notification fails to send')] - public function testLogsErrorWhenNotificationFails(): void - { - // Arrange - $protocol = $this->createMock(Protocol::class); - $session = $this->createMock(SessionInterface::class); - $uuid = Uuid::v4(); - $session->method('getId')->willReturn($uuid); - $uri = 'test://resource'; - - $session->method('get') - ->with('resource_subscriptions', []) - ->willReturnOnConsecutiveCalls( - [], - [$uri => true] - ); - - $session->expects($this->once())->method('set')->with('resource_subscriptions', [$uri => true]); - $session->expects($this->once())->method('save'); - - $this->subscriptionManager->subscribe($session, $uri); - - // Create a concrete exception that implements InvalidArgumentException - $exception = new class('Cache error') extends \Exception implements InvalidArgumentException {}; - - $protocol->expects($this->once()) - ->method('sendNotification') - ->willThrowException($exception); - - $this->logger->expects($this->once()) - ->method('error') - ->with( - 'Error sending resource notification to session', - $this->callback(static function ($context) use ($uuid, $uri, $exception) { - return $context['session_id'] === (string) $uuid - && $context['uri'] === $uri - && $context['exception'] === $exception; - }) - ); - - try { - // Act - $this->subscriptionManager->notifyResourceChanged($protocol, $session, $uri); - - $this->fail('Expected an exception to be thrown.'); - } catch (InvalidArgumentException $e) { - // Assert - $this->assertSame($exception, $e); - - return; - } - } -} diff --git a/tests/Unit/Server/Transport/Http/Middleware/AuthorizationMiddlewareTest.php b/tests/Unit/Server/Transport/Http/Middleware/AuthorizationMiddlewareTest.php deleted file mode 100644 index bd05af13..00000000 --- a/tests/Unit/Server/Transport/Http/Middleware/AuthorizationMiddlewareTest.php +++ /dev/null @@ -1,284 +0,0 @@ - - */ -class AuthorizationMiddlewareTest extends TestCase -{ - #[TestDox('missing Authorization header returns 401 with metadata and scope guidance')] - public function testMissingAuthorizationReturns401(): void - { - $factory = new Psr17Factory(); - $resourceMetadata = new ProtectedResourceMetadata( - authorizationServers: ['https://auth.example.com'], - scopesSupported: ['mcp:read'], - ); - $validator = new class implements AuthorizationTokenValidatorInterface { - public function validate(string $accessToken): AuthorizationResult - { - throw new RuntimeException('Validator should not be called without a token.'); - } - }; - - $middleware = new AuthorizationMiddleware( - validator: $validator, - resourceMetadata: $resourceMetadata, - responseFactory: $factory, - ); - - $request = $factory->createServerRequest('GET', 'https://mcp.example.com/mcp'); - $handler = new class($factory) implements RequestHandlerInterface { - public function __construct(private ResponseFactoryInterface $factory) - { - } - - public function handle(ServerRequestInterface $request): ResponseInterface - { - return $this->factory->createResponse(200); - } - }; - - $response = $middleware->process($request, $handler); - - $this->assertSame(401, $response->getStatusCode()); - $header = $response->getHeaderLine('WWW-Authenticate'); - $this->assertStringContainsString('Bearer', $header); - $this->assertStringContainsString( - 'resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource"', - $header, - ); - $this->assertStringContainsString('scope="mcp:read"', $header); - } - - #[TestDox('malformed Authorization header returns 400 with invalid_request')] - public function testMalformedAuthorizationReturns400(): void - { - $factory = new Psr17Factory(); - $resourceMetadata = new ProtectedResourceMetadata(['https://auth.example.com']); - $validator = new class implements AuthorizationTokenValidatorInterface { - public function validate(string $accessToken): AuthorizationResult - { - return AuthorizationResult::allow(); - } - }; - - $middleware = new AuthorizationMiddleware( - validator: $validator, - resourceMetadata: $resourceMetadata, - responseFactory: $factory, - ); - - $request = $factory->createServerRequest('GET', 'https://mcp.example.com/mcp') - ->withHeader('Authorization', 'Basic abc'); - - $handler = new class($factory) implements RequestHandlerInterface { - public function __construct(private ResponseFactoryInterface $factory) - { - } - - public function handle(ServerRequestInterface $request): ResponseInterface - { - return $this->factory->createResponse(200); - } - }; - - $response = $middleware->process($request, $handler); - - $this->assertSame(400, $response->getStatusCode()); - $this->assertStringContainsString('error="invalid_request"', $response->getHeaderLine('WWW-Authenticate')); - } - - #[TestDox('insufficient scopes return 403 with scope challenge')] - public function testInsufficientScopeReturns403(): void - { - $factory = new Psr17Factory(); - $resourceMetadata = new ProtectedResourceMetadata(['https://auth.example.com']); - $validator = new class implements AuthorizationTokenValidatorInterface { - public function validate(string $accessToken): AuthorizationResult - { - return AuthorizationResult::forbidden('insufficient_scope', 'Need more scopes.', ['mcp:write']); - } - }; - - $middleware = new AuthorizationMiddleware( - validator: $validator, - resourceMetadata: $resourceMetadata, - responseFactory: $factory, - ); - - $request = $factory->createServerRequest('GET', 'https://mcp.example.com/mcp') - ->withHeader('Authorization', 'Bearer token'); - - $handler = new class($factory) implements RequestHandlerInterface { - public function __construct(private ResponseFactoryInterface $factory) - { - } - - public function handle(ServerRequestInterface $request): ResponseInterface - { - return $this->factory->createResponse(200); - } - }; - - $response = $middleware->process($request, $handler); - - $this->assertSame(403, $response->getStatusCode()); - $header = $response->getHeaderLine('WWW-Authenticate'); - $this->assertStringContainsString('error="insufficient_scope"', $header); - $this->assertStringContainsString('scope="mcp:write"', $header); - } - - #[TestDox('metadata scopes are used in challenge when result has no scopes')] - public function testMetadataScopesAreUsedWhenResultHasNoScopes(): void - { - $factory = new Psr17Factory(); - $resourceMetadata = new ProtectedResourceMetadata( - authorizationServers: ['https://auth.example.com'], - scopesSupported: ['openid', 'profile'], - ); - $validator = new class implements AuthorizationTokenValidatorInterface { - public function validate(string $accessToken): AuthorizationResult - { - throw new RuntimeException('Validator should not be called without a token.'); - } - }; - - $middleware = new AuthorizationMiddleware( - validator: $validator, - resourceMetadata: $resourceMetadata, - responseFactory: $factory, - ); - - $request = $factory->createServerRequest('GET', 'https://mcp.example.com/mcp'); - $handler = new class($factory) implements RequestHandlerInterface { - public function __construct(private ResponseFactoryInterface $factory) - { - } - - public function handle(ServerRequestInterface $request): ResponseInterface - { - return $this->factory->createResponse(200); - } - }; - - $response = $middleware->process($request, $handler); - $header = $response->getHeaderLine('WWW-Authenticate'); - - $this->assertStringContainsString( - 'resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource"', - $header, - ); - $this->assertStringContainsString('scope="openid profile"', $header); - } - - #[TestDox('resource metadata object path and scopes are reflected in challenge')] - public function testResourceMetadataObjectProvidesMetadataAndScopes(): void - { - $factory = new Psr17Factory(); - $validator = new class implements AuthorizationTokenValidatorInterface { - public function validate(string $accessToken): AuthorizationResult - { - throw new RuntimeException('Validator should not be called without a token.'); - } - }; - - $resourceMetadata = new ProtectedResourceMetadata( - authorizationServers: ['https://auth.example.com'], - scopesSupported: ['openid', 'profile'], - metadataPaths: ['/oauth/resource-meta'], - ); - - $middleware = new AuthorizationMiddleware( - validator: $validator, - responseFactory: $factory, - resourceMetadata: $resourceMetadata, - ); - - $request = $factory->createServerRequest('GET', 'https://mcp.example.com/mcp'); - $handler = new class($factory) implements RequestHandlerInterface { - public function __construct(private ResponseFactoryInterface $factory) - { - } - - public function handle(ServerRequestInterface $request): ResponseInterface - { - return $this->factory->createResponse(200); - } - }; - - $response = $middleware->process($request, $handler); - $header = $response->getHeaderLine('WWW-Authenticate'); - - $this->assertSame(401, $response->getStatusCode()); - $this->assertStringContainsString( - 'resource_metadata="https://mcp.example.com/oauth/resource-meta"', - $header, - ); - $this->assertStringContainsString('scope="openid profile"', $header); - } - - #[TestDox('authorized requests reach the handler with attributes applied')] - public function testAllowedRequestPassesAttributes(): void - { - $factory = new Psr17Factory(); - $resourceMetadata = new ProtectedResourceMetadata(['https://auth.example.com']); - $validator = new class implements AuthorizationTokenValidatorInterface { - public function validate(string $accessToken): AuthorizationResult - { - return AuthorizationResult::allow(['subject' => 'user-1']); - } - }; - - $middleware = new AuthorizationMiddleware( - validator: $validator, - resourceMetadata: $resourceMetadata, - responseFactory: $factory, - ); - - $request = $factory->createServerRequest('GET', 'https://mcp.example.com/mcp') - ->withHeader('Authorization', 'Bearer token'); - - $handler = new class($factory) implements RequestHandlerInterface { - public function __construct(private ResponseFactoryInterface $factory) - { - } - - public function handle(ServerRequestInterface $request): ResponseInterface - { - return $this->factory->createResponse(200) - ->withHeader('X-Subject', (string) $request->getAttribute('subject')); - } - }; - - $response = $middleware->process($request, $handler); - - $this->assertSame(200, $response->getStatusCode()); - $this->assertSame('user-1', $response->getHeaderLine('X-Subject')); - } -} diff --git a/tests/Unit/Server/Transport/Http/Middleware/ClientRegistrationMiddlewareTest.php b/tests/Unit/Server/Transport/Http/Middleware/ClientRegistrationMiddlewareTest.php deleted file mode 100644 index 56364003..00000000 --- a/tests/Unit/Server/Transport/Http/Middleware/ClientRegistrationMiddlewareTest.php +++ /dev/null @@ -1,495 +0,0 @@ -factory = new Psr17Factory(); - } - - #[TestDox('POST /register with valid JSON delegates to registrar and returns 201')] - public function testRegistrationSuccess(): void - { - $registrar = $this->createMock(ClientRegistrarInterface::class); - $registrar->expects($this->once()) - ->method('register') - ->with(['redirect_uris' => ['https://example.com/callback']]) - ->willReturn(['client_id' => 'new-client', 'client_secret' => 's3cret']); - - $middleware = $this->createMiddleware($registrar); - - $request = $this->factory->createServerRequest('POST', 'http://localhost:8000/register') - ->withHeader('Content-Type', 'application/json') - ->withBody($this->factory->createStream(json_encode(['redirect_uris' => ['https://example.com/callback']]))); - - $response = $middleware->process($request, $this->createPassthroughHandler(404)); - - $this->assertSame(201, $response->getStatusCode()); - $this->assertSame('application/json', $response->getHeaderLine('Content-Type')); - $this->assertSame('no-store', $response->getHeaderLine('Cache-Control')); - - $payload = json_decode($response->getBody()->__toString(), true, 512, \JSON_THROW_ON_ERROR); - $this->assertSame('new-client', $payload['client_id']); - $this->assertSame('s3cret', $payload['client_secret']); - } - - #[TestDox('POST /register with invalid JSON returns 400')] - public function testRegistrationWithInvalidJson(): void - { - $registrar = $this->createStub(ClientRegistrarInterface::class); - - $middleware = $this->createMiddleware($registrar); - - $request = $this->factory->createServerRequest('POST', 'http://localhost:8000/register') - ->withHeader('Content-Type', 'application/json') - ->withBody($this->factory->createStream('not json')); - - $response = $middleware->process($request, $this->createPassthroughHandler(404)); - - $this->assertSame(400, $response->getStatusCode()); - - $payload = json_decode($response->getBody()->__toString(), true, 512, \JSON_THROW_ON_ERROR); - $this->assertSame('invalid_client_metadata', $payload['error']); - $this->assertSame('Request body must be valid JSON.', $payload['error_description']); - } - - #[TestDox('POST /register with JSON array instead of object returns 400')] - public function testRegistrationWithJsonArrayReturns400(): void - { - $registrar = $this->createStub(ClientRegistrarInterface::class); - - $middleware = $this->createMiddleware($registrar); - - $request = $this->factory->createServerRequest('POST', 'http://localhost:8000/register') - ->withHeader('Content-Type', 'application/json') - ->withBody($this->factory->createStream('["not","an","object"]')); - - $response = $middleware->process($request, $this->createPassthroughHandler(404)); - - $this->assertSame(400, $response->getStatusCode()); - - $payload = json_decode($response->getBody()->__toString(), true, 512, \JSON_THROW_ON_ERROR); - $this->assertSame('invalid_client_metadata', $payload['error']); - $this->assertSame('Request body must be a JSON object.', $payload['error_description']); - } - - #[TestDox('POST /register with empty JSON array returns 400')] - public function testRegistrationWithEmptyJsonArrayReturns400(): void - { - $registrar = $this->createStub(ClientRegistrarInterface::class); - - $middleware = $this->createMiddleware($registrar); - - $request = $this->factory->createServerRequest('POST', 'http://localhost:8000/register') - ->withHeader('Content-Type', 'application/json') - ->withBody($this->factory->createStream('[]')); - - $response = $middleware->process($request, $this->createPassthroughHandler(404)); - - $this->assertSame(400, $response->getStatusCode()); - - $payload = json_decode($response->getBody()->__toString(), true, 512, \JSON_THROW_ON_ERROR); - $this->assertSame('invalid_client_metadata', $payload['error']); - $this->assertSame('Request body must be a JSON object.', $payload['error_description']); - } - - #[TestDox('POST /register with nested JSON objects passes associative arrays to registrar')] - public function testRegistrationWithNestedObjectsPassesAssociativeArrays(): void - { - $registrar = $this->createMock(ClientRegistrarInterface::class); - $registrar->expects($this->once()) - ->method('register') - ->with($this->callback(function (array $data): bool { - // Nested objects must be associative arrays, not stdClass - $this->assertIsArray($data['jwks']); - $this->assertIsArray($data['jwks']['keys'][0]); - $this->assertSame('RSA', $data['jwks']['keys'][0]['kty']); - - return true; - })) - ->willReturn(['client_id' => 'nested-client']); - - $middleware = $this->createMiddleware($registrar); - - $body = json_encode([ - 'redirect_uris' => ['https://example.com/callback'], - 'jwks' => ['keys' => [['kty' => 'RSA', 'n' => 'abc', 'e' => 'AQAB']]], - ]); - - $request = $this->factory->createServerRequest('POST', 'http://localhost:8000/register') - ->withHeader('Content-Type', 'application/json') - ->withBody($this->factory->createStream($body)); - - $response = $middleware->process($request, $this->createPassthroughHandler(404)); - - $this->assertSame(201, $response->getStatusCode()); - } - - #[TestDox('POST /register error responses include Cache-Control: no-store')] - public function testRegistrationErrorResponsesIncludeCacheControl(): void - { - $registrar = $this->createStub(ClientRegistrarInterface::class); - $middleware = $this->createMiddleware($registrar); - - // Invalid JSON - $request = $this->factory->createServerRequest('POST', 'http://localhost:8000/register') - ->withHeader('Content-Type', 'application/json') - ->withBody($this->factory->createStream('not json')); - $response = $middleware->process($request, $this->createPassthroughHandler(404)); - $this->assertSame('no-store', $response->getHeaderLine('Cache-Control')); - - // JSON array (not object) - $request = $this->factory->createServerRequest('POST', 'http://localhost:8000/register') - ->withHeader('Content-Type', 'application/json') - ->withBody($this->factory->createStream('["array"]')); - $response = $middleware->process($request, $this->createPassthroughHandler(404)); - $this->assertSame('no-store', $response->getHeaderLine('Cache-Control')); - } - - #[TestDox('POST /register returns 400 when registrar throws ClientRegistrationException')] - public function testRegistrationWithRegistrarException(): void - { - $registrar = $this->createMock(ClientRegistrarInterface::class); - $registrar->expects($this->once()) - ->method('register') - ->willThrowException(new ClientRegistrationException('redirect_uris is required')); - - $middleware = $this->createMiddleware($registrar); - - $request = $this->factory->createServerRequest('POST', 'http://localhost:8000/register') - ->withHeader('Content-Type', 'application/json') - ->withBody($this->factory->createStream('{}')); - - $response = $middleware->process($request, $this->createPassthroughHandler(404)); - - $this->assertSame(400, $response->getStatusCode()); - $this->assertSame('no-store', $response->getHeaderLine('Cache-Control')); - - $payload = json_decode($response->getBody()->__toString(), true, 512, \JSON_THROW_ON_ERROR); - $this->assertSame('invalid_client_metadata', $payload['error']); - $this->assertSame('redirect_uris is required', $payload['error_description']); - } - - #[TestDox('POST /register without application/json Content-Type returns 400')] - public function testRegistrationRejectsNonJsonContentType(): void - { - $registrar = $this->createStub(ClientRegistrarInterface::class); - $middleware = $this->createMiddleware($registrar); - - $request = $this->factory->createServerRequest('POST', 'http://localhost:8000/register') - ->withHeader('Content-Type', 'application/x-www-form-urlencoded') - ->withBody($this->factory->createStream('key=value')); - - $response = $middleware->process($request, $this->createPassthroughHandler(404)); - - $this->assertSame(400, $response->getStatusCode()); - $this->assertSame('no-store', $response->getHeaderLine('Cache-Control')); - - $payload = json_decode($response->getBody()->__toString(), true, 512, \JSON_THROW_ON_ERROR); - $this->assertSame('invalid_client_metadata', $payload['error']); - $this->assertSame('Content-Type must be application/json.', $payload['error_description']); - } - - #[TestDox('POST /register uses error code from ClientRegistrationException')] - public function testRegistrationUsesCustomErrorCode(): void - { - $registrar = $this->createMock(ClientRegistrarInterface::class); - $registrar->expects($this->once()) - ->method('register') - ->willThrowException(new ClientRegistrationException('Invalid redirect URI', 'invalid_redirect_uri')); - - $middleware = $this->createMiddleware($registrar); - - $request = $this->factory->createServerRequest('POST', 'http://localhost:8000/register') - ->withHeader('Content-Type', 'application/json') - ->withBody($this->factory->createStream('{}')); - - $response = $middleware->process($request, $this->createPassthroughHandler(404)); - - $this->assertSame(400, $response->getStatusCode()); - - $payload = json_decode($response->getBody()->__toString(), true, 512, \JSON_THROW_ON_ERROR); - $this->assertSame('invalid_redirect_uri', $payload['error']); - $this->assertSame('Invalid redirect URI', $payload['error_description']); - } - - #[TestDox('GET /.well-known/oauth-authorization-server enriches response with registration_endpoint')] - public function testMetadataEnrichment(): void - { - $registrar = $this->createStub(ClientRegistrarInterface::class); - $middleware = $this->createMiddleware($registrar); - - $upstreamMetadata = [ - 'issuer' => 'http://localhost:8000', - 'authorization_endpoint' => 'http://localhost:8000/authorize', - 'token_endpoint' => 'http://localhost:8000/token', - ]; - - $request = $this->factory->createServerRequest('GET', 'http://localhost:8000/.well-known/oauth-authorization-server'); - $handler = $this->createJsonHandler(200, $upstreamMetadata); - - $response = $middleware->process($request, $handler); - - $this->assertSame(200, $response->getStatusCode()); - - $payload = json_decode($response->getBody()->__toString(), true, 512, \JSON_THROW_ON_ERROR); - $this->assertSame('http://localhost:8000/register', $payload['registration_endpoint']); - $this->assertSame('http://localhost:8000/authorize', $payload['authorization_endpoint']); - } - - #[TestDox('GET /.well-known/oauth-authorization-server preserves original response headers')] - public function testMetadataEnrichmentPreservesOriginalHeaders(): void - { - $registrar = $this->createStub(ClientRegistrarInterface::class); - $middleware = $this->createMiddleware($registrar); - - $request = $this->factory->createServerRequest('GET', 'http://localhost:8000/.well-known/oauth-authorization-server'); - $handler = $this->createJsonHandler(200, ['issuer' => 'http://localhost:8000'], 'max-age=3600', [ - 'X-Custom' => 'preserved', - 'Vary' => 'Origin', - ]); - - $response = $middleware->process($request, $handler); - - $this->assertSame('max-age=3600', $response->getHeaderLine('Cache-Control')); - $this->assertSame('preserved', $response->getHeaderLine('X-Custom')); - $this->assertSame('Origin', $response->getHeaderLine('Vary')); - } - - #[TestDox('GET /.well-known/oauth-authorization-server removes stale Content-Length after body mutation')] - public function testMetadataEnrichmentRemovesContentLength(): void - { - $registrar = $this->createStub(ClientRegistrarInterface::class); - $middleware = $this->createMiddleware($registrar); - - $request = $this->factory->createServerRequest('GET', 'http://localhost:8000/.well-known/oauth-authorization-server'); - $handler = $this->createJsonHandler(200, ['issuer' => 'http://localhost:8000'], '', [ - 'Content-Length' => '42', - ]); - - $response = $middleware->process($request, $handler); - - $this->assertFalse($response->hasHeader('Content-Length')); - } - - #[TestDox('GET /.well-known/oauth-authorization-server with invalid JSON body rewinds stream before returning')] - public function testMetadataEnrichmentRewindsStreamOnInvalidJsonBody(): void - { - $registrar = $this->createStub(ClientRegistrarInterface::class); - $middleware = $this->createMiddleware($registrar); - - $request = $this->factory->createServerRequest('GET', 'http://localhost:8000/.well-known/oauth-authorization-server'); - $handler = $this->createPlainTextHandler(200, 'not json'); - - $response = $middleware->process($request, $handler); - - $this->assertSame('not json', $response->getBody()->getContents()); - } - - #[TestDox('GET /.well-known/oauth-authorization-server with non-object JSON body rewinds stream before returning')] - public function testMetadataEnrichmentRewindsStreamOnNonObjectJsonBody(): void - { - $registrar = $this->createStub(ClientRegistrarInterface::class); - $middleware = $this->createMiddleware($registrar); - - $request = $this->factory->createServerRequest('GET', 'http://localhost:8000/.well-known/oauth-authorization-server'); - $handler = $this->createPlainTextHandler(200, '"just a string"'); - - $response = $middleware->process($request, $handler); - - $this->assertSame('"just a string"', $response->getBody()->getContents()); - } - - #[TestDox('GET /.well-known/oauth-authorization-server with JSON array body passes through unchanged')] - public function testMetadataEnrichmentPassesThroughJsonArrayBody(): void - { - $registrar = $this->createStub(ClientRegistrarInterface::class); - $middleware = $this->createMiddleware($registrar); - - $request = $this->factory->createServerRequest('GET', 'http://localhost:8000/.well-known/oauth-authorization-server'); - $handler = $this->createPlainTextHandler(200, '["not","an","object"]'); - - $response = $middleware->process($request, $handler); - - $this->assertSame('["not","an","object"]', $response->getBody()->getContents()); - } - - #[TestDox('GET /.well-known/oauth-authorization-server with non-200 status passes through unchanged')] - public function testMetadataNon200PassesThrough(): void - { - $registrar = $this->createStub(ClientRegistrarInterface::class); - $middleware = $this->createMiddleware($registrar); - - $request = $this->factory->createServerRequest('GET', 'http://localhost:8000/.well-known/oauth-authorization-server'); - $handler = $this->createPassthroughHandler(500); - - $response = $middleware->process($request, $handler); - - $this->assertSame(500, $response->getStatusCode()); - } - - #[TestDox('non-matching routes pass through to next handler')] - public function testNonMatchingRoutePassesThrough(): void - { - $registrar = $this->createStub(ClientRegistrarInterface::class); - $middleware = $this->createMiddleware($registrar); - - $request = $this->factory->createServerRequest('GET', 'http://localhost:8000/mcp'); - $handler = $this->createPassthroughHandler(204); - - $response = $middleware->process($request, $handler); - - $this->assertSame(204, $response->getStatusCode()); - } - - #[TestDox('constructor rejects empty localBaseUrl')] - public function testConstructorRejectsEmptyBaseUrl(): void - { - $this->expectException(InvalidArgumentException::class); - - new ClientRegistrationMiddleware( - $this->createStub(ClientRegistrarInterface::class), - '', - $this->factory, - $this->factory, - ); - } - - #[TestDox('localBaseUrl trailing slash is normalized in registration_endpoint')] - public function testTrailingSlashNormalization(): void - { - $registrar = $this->createStub(ClientRegistrarInterface::class); - - $middleware = new ClientRegistrationMiddleware( - $registrar, - 'http://localhost:8000/', - $this->factory, - $this->factory, - ); - - $request = $this->factory->createServerRequest('GET', 'http://localhost:8000/.well-known/oauth-authorization-server'); - $handler = $this->createJsonHandler(200, ['issuer' => 'http://localhost:8000']); - - $response = $middleware->process($request, $handler); - - $payload = json_decode($response->getBody()->__toString(), true, 512, \JSON_THROW_ON_ERROR); - $this->assertSame('http://localhost:8000/register', $payload['registration_endpoint']); - } - - private function createMiddleware(ClientRegistrarInterface $registrar): ClientRegistrationMiddleware - { - return new ClientRegistrationMiddleware( - $registrar, - 'http://localhost:8000', - $this->factory, - $this->factory, - ); - } - - private function createPassthroughHandler(int $status): RequestHandlerInterface - { - $factory = $this->factory; - - return new class($factory, $status) implements RequestHandlerInterface { - public function __construct( - private readonly ResponseFactoryInterface $factory, - private readonly int $status, - ) { - } - - public function handle(ServerRequestInterface $request): ResponseInterface - { - return $this->factory->createResponse($this->status); - } - }; - } - - /** - * @param array $data - * @param array $extraHeaders - */ - private function createJsonHandler(int $status, array $data, string $cacheControl = '', array $extraHeaders = []): RequestHandlerInterface - { - $factory = $this->factory; - - return new class($factory, $status, $data, $cacheControl, $extraHeaders) implements RequestHandlerInterface { - /** - * @param array $data - * @param array $extraHeaders - */ - public function __construct( - private readonly ResponseFactoryInterface $factory, - private readonly int $status, - private readonly array $data, - private readonly string $cacheControl, - private readonly array $extraHeaders, - ) { - } - - public function handle(ServerRequestInterface $request): ResponseInterface - { - $response = $this->factory->createResponse($this->status) - ->withHeader('Content-Type', 'application/json') - ->withBody((new Psr17Factory())->createStream( - json_encode($this->data, \JSON_THROW_ON_ERROR | \JSON_UNESCAPED_SLASHES), - )); - - if ('' !== $this->cacheControl) { - $response = $response->withHeader('Cache-Control', $this->cacheControl); - } - - foreach ($this->extraHeaders as $name => $value) { - $response = $response->withHeader($name, $value); - } - - return $response; - } - }; - } - - private function createPlainTextHandler(int $status, string $body): RequestHandlerInterface - { - $factory = $this->factory; - - return new class($factory, $status, $body) implements RequestHandlerInterface { - public function __construct( - private readonly ResponseFactoryInterface $factory, - private readonly int $status, - private readonly string $body, - ) { - } - - public function handle(ServerRequestInterface $request): ResponseInterface - { - return $this->factory->createResponse($this->status) - ->withHeader('Content-Type', 'text/plain') - ->withBody((new Psr17Factory())->createStream($this->body)); - } - }; - } -} diff --git a/tests/Unit/Server/Transport/Http/Middleware/CorsMiddlewareTest.php b/tests/Unit/Server/Transport/Http/Middleware/CorsMiddlewareTest.php deleted file mode 100644 index c38353f8..00000000 --- a/tests/Unit/Server/Transport/Http/Middleware/CorsMiddlewareTest.php +++ /dev/null @@ -1,269 +0,0 @@ -factory->createServerRequest('POST', 'https://example.com') - ->withHeader('Origin', 'https://evil.example.com'); - - $response = $middleware->process($request, $this->passthroughHandler); - - $this->assertFalse($response->hasHeader('Access-Control-Allow-Origin')); - $this->assertTrue($response->hasHeader('Access-Control-Expose-Headers')); - // Non-preflight: Methods/Headers must NOT be emitted per CORS spec. - $this->assertFalse($response->hasHeader('Access-Control-Allow-Methods')); - $this->assertFalse($response->hasHeader('Access-Control-Allow-Headers')); - } - - #[TestDox('preflight request receives Access-Control-Allow-Methods and Access-Control-Allow-Headers')] - public function testPreflightReceivesMethodAndHeaderAdvertisements(): void - { - $middleware = new CorsMiddleware(allowedOrigins: ['https://app.example.com']); - $request = $this->preflightRequest('https://app.example.com'); - - $response = $middleware->process($request, $this->passthroughHandler); - - $this->assertSame('GET, POST, DELETE', $response->getHeaderLine('Access-Control-Allow-Methods')); - $this->assertNotSame('', $response->getHeaderLine('Access-Control-Allow-Headers')); - } - - #[TestDox('non-preflight OPTIONS request does not receive Methods/Headers advertisements')] - public function testPlainOptionsIsNotTreatedAsPreflight(): void - { - $middleware = new CorsMiddleware(allowedOrigins: ['*']); - // OPTIONS without `Access-Control-Request-Method` is not a CORS preflight. - $request = $this->factory->createServerRequest('OPTIONS', 'https://example.com'); - - $response = $middleware->process($request, $this->passthroughHandler); - - $this->assertFalse($response->hasHeader('Access-Control-Allow-Methods')); - $this->assertFalse($response->hasHeader('Access-Control-Allow-Headers')); - } - - #[TestDox('wildcard allowedOrigins sets Access-Control-Allow-Origin to *')] - public function testWildcardOrigin(): void - { - $middleware = new CorsMiddleware(allowedOrigins: ['*']); - $request = $this->factory->createServerRequest('POST', 'https://example.com'); - - $response = $middleware->process($request, $this->passthroughHandler); - - $this->assertSame('*', $response->getHeaderLine('Access-Control-Allow-Origin')); - } - - #[TestDox('matching Origin is reflected back')] - public function testMatchingOriginIsReflected(): void - { - $middleware = new CorsMiddleware( - allowedOrigins: ['https://app.example.com', 'https://staging.example.com'], - ); - $request = $this->factory->createServerRequest('POST', 'https://example.com') - ->withHeader('Origin', 'https://app.example.com'); - - $response = $middleware->process($request, $this->passthroughHandler); - - $this->assertSame('https://app.example.com', $response->getHeaderLine('Access-Control-Allow-Origin')); - } - - #[TestDox('non-matching Origin is not echoed')] - public function testNonMatchingOriginIsBlocked(): void - { - $middleware = new CorsMiddleware(allowedOrigins: ['https://app.example.com']); - $request = $this->factory->createServerRequest('POST', 'https://example.com') - ->withHeader('Origin', 'https://evil.example.com'); - - $response = $middleware->process($request, $this->passthroughHandler); - - $this->assertFalse($response->hasHeader('Access-Control-Allow-Origin')); - } - - #[TestDox('does not overwrite headers set by inner middleware')] - public function testPreExistingHeadersAreNotOverwritten(): void - { - $inner = $this->handlerReturning(200, [ - 'Access-Control-Allow-Origin' => 'https://override.example.com', - 'Access-Control-Allow-Methods' => 'POST', - ]); - - $middleware = new CorsMiddleware(allowedOrigins: ['*']); - $request = $this->preflightRequest(); - - $response = $middleware->process($request, $inner); - - $this->assertSame('https://override.example.com', $response->getHeaderLine('Access-Control-Allow-Origin')); - $this->assertSame('POST', $response->getHeaderLine('Access-Control-Allow-Methods')); - } - - #[TestDox('exposed headers can be omitted')] - public function testEmptyExposedHeadersAreNotSet(): void - { - $middleware = new CorsMiddleware(allowedOrigins: ['*'], exposedHeaders: []); - $request = $this->factory->createServerRequest('POST', 'https://example.com'); - - $response = $middleware->process($request, $this->passthroughHandler); - - $this->assertFalse($response->hasHeader('Access-Control-Expose-Headers')); - } - - #[TestDox('adds Vary: Origin when reflecting a specific origin to protect caches')] - public function testVaryOriginIsAddedForReflectedOrigin(): void - { - $middleware = new CorsMiddleware(allowedOrigins: ['https://app.example.com']); - $request = $this->factory->createServerRequest('POST', 'https://example.com') - ->withHeader('Origin', 'https://app.example.com'); - - $response = $middleware->process($request, $this->passthroughHandler); - - $this->assertSame('Origin', $response->getHeaderLine('Vary')); - } - - #[TestDox('adds Vary: Origin even when origin is rejected so caches do not poison')] - public function testVaryOriginIsAddedEvenWhenOriginDoesNotMatch(): void - { - $middleware = new CorsMiddleware(allowedOrigins: ['https://app.example.com']); - $request = $this->factory->createServerRequest('POST', 'https://example.com') - ->withHeader('Origin', 'https://evil.example.com'); - - $response = $middleware->process($request, $this->passthroughHandler); - - $this->assertFalse($response->hasHeader('Access-Control-Allow-Origin')); - $this->assertSame('Origin', $response->getHeaderLine('Vary')); - } - - #[TestDox('does not add Vary when Access-Control-Allow-Origin is wildcard')] - public function testVaryOriginIsNotAddedForWildcard(): void - { - $middleware = new CorsMiddleware(allowedOrigins: ['*']); - $request = $this->factory->createServerRequest('POST', 'https://example.com') - ->withHeader('Origin', 'https://app.example.com'); - - $response = $middleware->process($request, $this->passthroughHandler); - - $this->assertSame('*', $response->getHeaderLine('Access-Control-Allow-Origin')); - $this->assertFalse($response->hasHeader('Vary')); - } - - #[TestDox('does not add Vary when no allowed origins are configured')] - public function testVaryOriginIsNotAddedWhenAllowedOriginsEmpty(): void - { - $middleware = new CorsMiddleware(); - $request = $this->factory->createServerRequest('POST', 'https://example.com') - ->withHeader('Origin', 'https://app.example.com'); - - $response = $middleware->process($request, $this->passthroughHandler); - - $this->assertFalse($response->hasHeader('Vary')); - } - - #[TestDox('preserves existing Vary value when appending Origin')] - public function testVaryOriginAppendsToExistingVary(): void - { - $inner = $this->handlerReturning(200, ['Vary' => 'Accept-Encoding']); - - $middleware = new CorsMiddleware(allowedOrigins: ['https://app.example.com']); - $request = $this->factory->createServerRequest('POST', 'https://example.com') - ->withHeader('Origin', 'https://app.example.com'); - - $response = $middleware->process($request, $inner); - - $this->assertSame('Accept-Encoding, Origin', $response->getHeaderLine('Vary')); - } - - #[TestDox('does not duplicate Origin in existing Vary header')] - public function testVaryOriginIsNotDuplicated(): void - { - $inner = $this->handlerReturning(200, ['Vary' => 'Accept-Encoding, Origin']); - - $middleware = new CorsMiddleware(allowedOrigins: ['https://app.example.com']); - $request = $this->factory->createServerRequest('POST', 'https://example.com') - ->withHeader('Origin', 'https://app.example.com'); - - $response = $middleware->process($request, $inner); - - $this->assertSame('Accept-Encoding, Origin', $response->getHeaderLine('Vary')); - } - - #[TestDox('does not treat a substring match like Origin-Other as the Origin token')] - public function testVarySubstringDoesNotPreventAppending(): void - { - // `Origin-Resource-Policy` contains the substring "origin" but is a different token — - // tokenized comparison must still treat the response as missing the `Origin` value. - $inner = $this->handlerReturning(200, ['Vary' => 'Origin-Resource-Policy']); - - $middleware = new CorsMiddleware(allowedOrigins: ['https://app.example.com']); - $request = $this->factory->createServerRequest('POST', 'https://example.com') - ->withHeader('Origin', 'https://app.example.com'); - - $response = $middleware->process($request, $inner); - - $this->assertSame('Origin-Resource-Policy, Origin', $response->getHeaderLine('Vary')); - } - - #[TestDox('allowCredentials emits Access-Control-Allow-Credentials when an origin matches')] - public function testAllowCredentialsHeaderEmitted(): void - { - $middleware = new CorsMiddleware( - allowedOrigins: ['https://app.example.com'], - allowCredentials: true, - ); - $request = $this->factory->createServerRequest('POST', 'https://example.com') - ->withHeader('Origin', 'https://app.example.com'); - - $response = $middleware->process($request, $this->passthroughHandler); - - $this->assertSame('https://app.example.com', $response->getHeaderLine('Access-Control-Allow-Origin')); - $this->assertSame('true', $response->getHeaderLine('Access-Control-Allow-Credentials')); - } - - #[TestDox('allowCredentials does not emit credentials header when no origin matches')] - public function testAllowCredentialsSkippedWhenOriginUnmatched(): void - { - $middleware = new CorsMiddleware( - allowedOrigins: ['https://app.example.com'], - allowCredentials: true, - ); - $request = $this->factory->createServerRequest('POST', 'https://example.com') - ->withHeader('Origin', 'https://evil.example.com'); - - $response = $middleware->process($request, $this->passthroughHandler); - - $this->assertFalse($response->hasHeader('Access-Control-Allow-Origin')); - $this->assertFalse($response->hasHeader('Access-Control-Allow-Credentials')); - } - - #[TestDox('combining wildcard origin with allowCredentials throws')] - public function testWildcardWithCredentialsRejected(): void - { - $this->expectException(InvalidArgumentException::class); - - new CorsMiddleware(allowedOrigins: ['*'], allowCredentials: true); - } - - private function preflightRequest(string $origin = 'https://app.example.com'): ServerRequestInterface - { - return $this->factory - ->createServerRequest('OPTIONS', 'https://example.com') - ->withHeader('Origin', $origin) - ->withHeader('Access-Control-Request-Method', 'POST') - ->withHeader('Access-Control-Request-Headers', 'Content-Type'); - } -} diff --git a/tests/Unit/Server/Transport/Http/Middleware/DnsRebindingProtectionMiddlewareTest.php b/tests/Unit/Server/Transport/Http/Middleware/DnsRebindingProtectionMiddlewareTest.php deleted file mode 100644 index 720a0f27..00000000 --- a/tests/Unit/Server/Transport/Http/Middleware/DnsRebindingProtectionMiddlewareTest.php +++ /dev/null @@ -1,145 +0,0 @@ - ['http://localhost:8000']; - yield 'IPv4 loopback' => ['http://127.0.0.1:3000']; - yield 'IPv6 loopback (bracketed)' => ['http://[::1]:8000']; - } - - #[DataProvider('allowedOriginProvider')] - #[TestDox('allows request with localhost Origin variant: $origin')] - public function testAllowsLocalhostOrigin(string $origin): void - { - $middleware = new DnsRebindingProtectionMiddleware(responseFactory: $this->factory, streamFactory: $this->factory); - $request = $this->factory->createServerRequest('POST', 'http://localhost/') - ->withHeader('Origin', $origin); - - $response = $middleware->process($request, $this->passthroughHandler); - - $this->assertSame(200, $response->getStatusCode()); - } - - #[TestDox('rejects non-allowed Origin with 403')] - public function testRejectsForeignOrigin(): void - { - $middleware = new DnsRebindingProtectionMiddleware(responseFactory: $this->factory, streamFactory: $this->factory); - $request = $this->factory->createServerRequest('POST', 'http://localhost/') - ->withHeader('Origin', 'http://evil.example.com'); - - $response = $middleware->process($request, $this->passthroughHandler); - - $this->assertSame(403, $response->getStatusCode()); - $this->assertSame('text/plain', $response->getHeaderLine('Content-Type')); - $this->assertStringContainsString('Origin', (string) $response->getBody()); - } - - #[TestDox('Origin header takes precedence over Host')] - public function testOriginPrecedenceOverHost(): void - { - $middleware = new DnsRebindingProtectionMiddleware(responseFactory: $this->factory, streamFactory: $this->factory); - $request = $this->factory->createServerRequest('POST', 'http://localhost/') - ->withHeader('Origin', 'http://localhost:8000') - ->withHeader('Host', 'evil.example.com'); - - $response = $middleware->process($request, $this->passthroughHandler); - - $this->assertSame(200, $response->getStatusCode()); - } - - #[TestDox('validates Host header when Origin is absent')] - public function testFallbackToHostValidation(): void - { - $middleware = new DnsRebindingProtectionMiddleware(responseFactory: $this->factory, streamFactory: $this->factory); - $request = $this->factory->createServerRequest('POST', 'http://evil/') - ->withHeader('Host', 'evil.example.com'); - - $response = $middleware->process($request, $this->passthroughHandler); - - $this->assertSame(403, $response->getStatusCode()); - } - - #[TestDox('strips port from Host header when validating')] - public function testHostPortIsStripped(): void - { - $middleware = new DnsRebindingProtectionMiddleware(responseFactory: $this->factory, streamFactory: $this->factory); - $request = $this->factory->createServerRequest('POST', 'http://localhost/') - ->withHeader('Host', 'localhost:8000'); - - $response = $middleware->process($request, $this->passthroughHandler); - - $this->assertSame(200, $response->getStatusCode()); - } - - #[TestDox('IPv6 Host with port is parsed correctly')] - public function testIpv6HostWithPort(): void - { - $middleware = new DnsRebindingProtectionMiddleware(responseFactory: $this->factory, streamFactory: $this->factory); - $request = $this->factory->createServerRequest('POST', 'http://localhost/') - ->withHeader('Host', '[::1]:8080'); - - $response = $middleware->process($request, $this->passthroughHandler); - - $this->assertSame(200, $response->getStatusCode()); - } - - #[TestDox('custom allowed hosts permit non-localhost names')] - public function testCustomAllowedHosts(): void - { - $middleware = new DnsRebindingProtectionMiddleware( - allowedHosts: ['myapp.local'], - responseFactory: $this->factory, - streamFactory: $this->factory, - ); - $request = $this->factory->createServerRequest('POST', 'http://myapp.local/') - ->withHeader('Origin', 'http://myapp.local:3000'); - - $response = $middleware->process($request, $this->passthroughHandler); - - $this->assertSame(200, $response->getStatusCode()); - } - - #[TestDox('host comparison is case-insensitive')] - public function testCaseInsensitive(): void - { - $middleware = new DnsRebindingProtectionMiddleware( - allowedHosts: ['MyApp.Local'], - responseFactory: $this->factory, - streamFactory: $this->factory, - ); - $request = $this->factory->createServerRequest('POST', 'http://myapp.local/') - ->withHeader('Origin', 'http://MYAPP.LOCAL:80'); - - $response = $middleware->process($request, $this->passthroughHandler); - - $this->assertSame(200, $response->getStatusCode()); - } - - #[TestDox('request without Origin or Host is allowed')] - public function testNoOriginNoHostPasses(): void - { - $middleware = new DnsRebindingProtectionMiddleware(responseFactory: $this->factory, streamFactory: $this->factory); - $request = $this->factory->createServerRequest('POST', 'http://localhost/')->withoutHeader('Host'); - - $response = $middleware->process($request, $this->passthroughHandler); - - $this->assertSame(200, $response->getStatusCode()); - } -} diff --git a/tests/Unit/Server/Transport/Http/Middleware/MiddlewareTestCase.php b/tests/Unit/Server/Transport/Http/Middleware/MiddlewareTestCase.php deleted file mode 100644 index 3f5ba2fe..00000000 --- a/tests/Unit/Server/Transport/Http/Middleware/MiddlewareTestCase.php +++ /dev/null @@ -1,57 +0,0 @@ -factory = new Psr17Factory(); - $this->passthroughHandler = $this->handlerReturning(200); - } - - /** - * @param array $headers extra headers to set on the response (already-set CORS headers etc.) - */ - protected function handlerReturning(int $status, array $headers = []): RequestHandlerInterface - { - return new class($this->factory, $status, $headers) implements RequestHandlerInterface { - /** @param array $headers */ - public function __construct( - private ResponseFactoryInterface $factory, - private int $status, - private array $headers, - ) { - } - - public function handle(ServerRequestInterface $request): ResponseInterface - { - $response = $this->factory->createResponse($this->status); - foreach ($this->headers as $name => $value) { - $response = $response->withHeader($name, $value); - } - - return $response; - } - }; - } -} diff --git a/tests/Unit/Server/Transport/Http/Middleware/OAuthProxyMiddlewareTest.php b/tests/Unit/Server/Transport/Http/Middleware/OAuthProxyMiddlewareTest.php deleted file mode 100644 index 4ab36616..00000000 --- a/tests/Unit/Server/Transport/Http/Middleware/OAuthProxyMiddlewareTest.php +++ /dev/null @@ -1,299 +0,0 @@ - - */ -class OAuthProxyMiddlewareTest extends TestCase -{ - #[TestDox('metadata endpoint returns local oauth metadata with upstream capabilities')] - public function testMetadataEndpointReturnsLocalMetadata(): void - { - $factory = new Psr17Factory(); - $discovery = $this->createMock(OidcDiscoveryInterface::class); - $discovery->expects($this->once()) - ->method('discover') - ->with('https://login.example.com/tenant') - ->willReturn([ - 'authorization_endpoint' => 'https://login.example.com/oauth2/v2.0/authorize', - 'token_endpoint' => 'https://login.example.com/oauth2/v2.0/token', - 'jwks_uri' => 'https://login.example.com/discovery/v2.0/keys', - 'response_types_supported' => ['code'], - 'grant_types_supported' => ['authorization_code', 'refresh_token'], - 'code_challenge_methods_supported' => ['S256'], - 'scopes_supported' => ['openid', 'profile'], - 'token_endpoint_auth_methods_supported' => ['client_secret_post'], - ]); - - $middleware = new OAuthProxyMiddleware( - upstreamIssuer: 'https://login.example.com/tenant', - localBaseUrl: 'http://localhost:8000', - discovery: $discovery, - responseFactory: $factory, - streamFactory: $factory, - ); - - $request = $factory->createServerRequest('GET', 'http://localhost:8000/.well-known/oauth-authorization-server'); - $handler = new class($factory) implements RequestHandlerInterface { - public function __construct(private ResponseFactoryInterface $factory) - { - } - - public function handle(ServerRequestInterface $request): ResponseInterface - { - return $this->factory->createResponse(404); - } - }; - - $response = $middleware->process($request, $handler); - - $this->assertSame(200, $response->getStatusCode()); - $payload = json_decode($response->getBody()->__toString(), true, 512, \JSON_THROW_ON_ERROR); - $this->assertSame('http://localhost:8000', $payload['issuer']); - $this->assertSame('http://localhost:8000/authorize', $payload['authorization_endpoint']); - $this->assertSame('http://localhost:8000/token', $payload['token_endpoint']); - $this->assertSame(['openid', 'profile'], $payload['scopes_supported']); - $this->assertSame('https://login.example.com/discovery/v2.0/keys', $payload['jwks_uri']); - } - - #[TestDox('authorize endpoint redirects to upstream authorization endpoint preserving query')] - public function testAuthorizeEndpointRedirectsToUpstream(): void - { - $factory = new Psr17Factory(); - $discovery = $this->createMock(OidcDiscoveryInterface::class); - $discovery->expects($this->once()) - ->method('getAuthorizationEndpoint') - ->with('https://login.example.com/tenant') - ->willReturn('https://login.example.com/oauth2/v2.0/authorize'); - - $middleware = new OAuthProxyMiddleware( - upstreamIssuer: 'https://login.example.com/tenant', - localBaseUrl: 'http://localhost:8000', - discovery: $discovery, - responseFactory: $factory, - streamFactory: $factory, - ); - - $request = $factory->createServerRequest( - 'GET', - 'http://localhost:8000/authorize?client_id=test-client&scope=openid%20profile&code_challenge=abc', - ); - - $handler = new class($factory) implements RequestHandlerInterface { - public function __construct(private ResponseFactoryInterface $factory) - { - } - - public function handle(ServerRequestInterface $request): ResponseInterface - { - return $this->factory->createResponse(404); - } - }; - - $response = $middleware->process($request, $handler); - - $this->assertSame(302, $response->getStatusCode()); - $this->assertSame( - 'https://login.example.com/oauth2/v2.0/authorize?client_id=test-client&scope=openid%20profile&code_challenge=abc', - $response->getHeaderLine('Location'), - ); - } - - #[TestDox('token endpoint proxies request and injects client secret')] - public function testTokenEndpointProxiesRequestAndInjectsClientSecret(): void - { - $factory = new Psr17Factory(); - $discovery = $this->createMock(OidcDiscoveryInterface::class); - $discovery->expects($this->once()) - ->method('getTokenEndpoint') - ->with('https://login.example.com/tenant') - ->willReturn('https://login.example.com/oauth2/v2.0/token'); - $discovery->expects($this->once()) - ->method('discover') - ->with('https://login.example.com/tenant') - ->willReturn([ - 'authorization_endpoint' => 'https://login.example.com/oauth2/v2.0/authorize', - 'token_endpoint' => 'https://login.example.com/oauth2/v2.0/token', - 'jwks_uri' => 'https://login.example.com/discovery/v2.0/keys', - 'token_endpoint_auth_methods_supported' => ['client_secret_post'], - ]); - - $httpClient = $this->createMock(ClientInterface::class); - $httpClient->expects($this->once()) - ->method('sendRequest') - ->willReturnCallback(function (RequestInterface $request) use ($factory): ResponseInterface { - $this->assertSame('POST', $request->getMethod()); - $this->assertSame('https://login.example.com/oauth2/v2.0/token', (string) $request->getUri()); - $this->assertSame('', $request->getHeaderLine('Authorization')); - $this->assertSame('application/x-www-form-urlencoded', $request->getHeaderLine('Content-Type')); - - parse_str($request->getBody()->__toString(), $params); - $this->assertSame('authorization_code', $params['grant_type'] ?? null); - $this->assertSame('abc123', $params['code'] ?? null); - $this->assertSame('secret-value', $params['client_secret'] ?? null); - - return $factory->createResponse(200) - ->withHeader('Content-Type', 'application/json') - ->withBody($factory->createStream('{"access_token":"token-1"}')); - }); - - $middleware = new OAuthProxyMiddleware( - upstreamIssuer: 'https://login.example.com/tenant', - localBaseUrl: 'http://localhost:8000', - clientSecret: 'secret-value', - discovery: $discovery, - httpClient: $httpClient, - requestFactory: $factory, - responseFactory: $factory, - streamFactory: $factory, - ); - - $request = $factory->createServerRequest('POST', 'http://localhost:8000/token') - ->withHeader('Content-Type', 'application/x-www-form-urlencoded') - ->withBody($factory->createStream('grant_type=authorization_code&code=abc123')); - - $handler = new class($factory) implements RequestHandlerInterface { - public function __construct(private ResponseFactoryInterface $factory) - { - } - - public function handle(ServerRequestInterface $request): ResponseInterface - { - return $this->factory->createResponse(404); - } - }; - - $response = $middleware->process($request, $handler); - - $this->assertSame(200, $response->getStatusCode()); - $this->assertSame('application/json', $response->getHeaderLine('Content-Type')); - $this->assertSame('{"access_token":"token-1"}', $response->getBody()->__toString()); - } - - #[TestDox('token endpoint uses client_secret_basic when supported by upstream metadata')] - public function testTokenEndpointUsesClientSecretBasicWhenSupported(): void - { - $factory = new Psr17Factory(); - $discovery = $this->createMock(OidcDiscoveryInterface::class); - $discovery->expects($this->once()) - ->method('getTokenEndpoint') - ->with('https://login.example.com/tenant') - ->willReturn('https://login.example.com/oauth2/v2.0/token'); - $discovery->expects($this->once()) - ->method('discover') - ->with('https://login.example.com/tenant') - ->willReturn([ - 'authorization_endpoint' => 'https://login.example.com/oauth2/v2.0/authorize', - 'token_endpoint' => 'https://login.example.com/oauth2/v2.0/token', - 'jwks_uri' => 'https://login.example.com/discovery/v2.0/keys', - 'token_endpoint_auth_methods_supported' => ['client_secret_basic'], - ]); - - $httpClient = $this->createMock(ClientInterface::class); - $httpClient->expects($this->once()) - ->method('sendRequest') - ->willReturnCallback(function (RequestInterface $request) use ($factory): ResponseInterface { - $this->assertSame('POST', $request->getMethod()); - $this->assertSame('https://login.example.com/oauth2/v2.0/token', (string) $request->getUri()); - $this->assertSame('Basic ZGVtby1jbGllbnQ6c2VjcmV0LXZhbHVl', $request->getHeaderLine('Authorization')); - $this->assertSame('application/x-www-form-urlencoded', $request->getHeaderLine('Content-Type')); - - parse_str($request->getBody()->__toString(), $params); - $this->assertSame('authorization_code', $params['grant_type'] ?? null); - $this->assertSame('abc123', $params['code'] ?? null); - $this->assertArrayNotHasKey('client_secret', $params); - - return $factory->createResponse(200) - ->withHeader('Content-Type', 'application/json') - ->withBody($factory->createStream('{"access_token":"token-1"}')); - }); - - $middleware = new OAuthProxyMiddleware( - upstreamIssuer: 'https://login.example.com/tenant', - localBaseUrl: 'http://localhost:8000', - clientSecret: 'secret-value', - discovery: $discovery, - httpClient: $httpClient, - requestFactory: $factory, - responseFactory: $factory, - streamFactory: $factory, - ); - - $request = $factory->createServerRequest('POST', 'http://localhost:8000/token') - ->withHeader('Content-Type', 'application/x-www-form-urlencoded') - ->withBody($factory->createStream('grant_type=authorization_code&client_id=demo-client&code=abc123')); - - $handler = new class($factory) implements RequestHandlerInterface { - public function __construct(private ResponseFactoryInterface $factory) - { - } - - public function handle(ServerRequestInterface $request): ResponseInterface - { - return $this->factory->createResponse(404); - } - }; - - $response = $middleware->process($request, $handler); - - $this->assertSame(200, $response->getStatusCode()); - $this->assertSame('application/json', $response->getHeaderLine('Content-Type')); - $this->assertSame('{"access_token":"token-1"}', $response->getBody()->__toString()); - } - - #[TestDox('non oauth proxy requests are delegated to next middleware')] - public function testNonOAuthRequestPassesThrough(): void - { - $factory = new Psr17Factory(); - $discovery = $this->createMock(OidcDiscoveryInterface::class); - $discovery->expects($this->never())->method('discover'); - - $middleware = new OAuthProxyMiddleware( - upstreamIssuer: 'https://login.example.com/tenant', - localBaseUrl: 'http://localhost:8000', - discovery: $discovery, - responseFactory: $factory, - streamFactory: $factory, - ); - - $request = $factory->createServerRequest('GET', 'http://localhost:8000/mcp'); - $handler = new class($factory) implements RequestHandlerInterface { - public function __construct(private ResponseFactoryInterface $factory) - { - } - - public function handle(ServerRequestInterface $request): ResponseInterface - { - return $this->factory->createResponse(204); - } - }; - - $response = $middleware->process($request, $handler); - - $this->assertSame(204, $response->getStatusCode()); - } -} diff --git a/tests/Unit/Server/Transport/Http/Middleware/OAuthRequestMetaMiddlewareTest.php b/tests/Unit/Server/Transport/Http/Middleware/OAuthRequestMetaMiddlewareTest.php deleted file mode 100644 index c66dfb4e..00000000 --- a/tests/Unit/Server/Transport/Http/Middleware/OAuthRequestMetaMiddlewareTest.php +++ /dev/null @@ -1,179 +0,0 @@ - - */ -class OAuthRequestMetaMiddlewareTest extends TestCase -{ - #[TestDox('oauth request attributes are copied to json-rpc params _meta')] - public function testInjectsOauthAttributesIntoSingleRequest(): void - { - $factory = new Psr17Factory(); - $middleware = new OAuthRequestMetaMiddleware($factory); - - $payload = [ - 'jsonrpc' => '2.0', - 'id' => 1, - 'method' => 'initialize', - 'params' => [ - 'protocolVersion' => '2024-11-05', - ], - ]; - - $request = $factory - ->createServerRequest('POST', 'https://mcp.example.com/mcp') - ->withBody($factory->createStream(json_encode($payload, \JSON_THROW_ON_ERROR))) - ->withAttribute('oauth.claims', ['sub' => 'user-1']) - ->withAttribute('oauth.scopes', ['openid', 'profile']) - ->withAttribute('oauth.subject', 'user-1') - ->withAttribute('not_oauth', 'ignored'); - - $response = $middleware->process($request, $this->createEchoHandler($factory)); - $decoded = json_decode($response->getBody()->__toString(), true, 512, \JSON_THROW_ON_ERROR); - - $this->assertSame(['sub' => 'user-1'], $decoded['params']['_meta']['oauth']['oauth.claims']); - $this->assertSame(['openid', 'profile'], $decoded['params']['_meta']['oauth']['oauth.scopes']); - $this->assertSame('user-1', $decoded['params']['_meta']['oauth']['oauth.subject']); - $this->assertArrayNotHasKey('not_oauth', $decoded['params']['_meta']['oauth']); - } - - #[TestDox('existing _meta is preserved and oauth keys are merged')] - public function testMergesWithExistingMeta(): void - { - $factory = new Psr17Factory(); - $middleware = new OAuthRequestMetaMiddleware($factory); - - $payload = [ - 'jsonrpc' => '2.0', - 'id' => 1, - 'method' => 'tools/list', - 'params' => [ - '_meta' => [ - 'trace_id' => 'trace-1', - 'oauth' => [ - 'client_hint' => 'web', - 'oauth.subject' => 'spoofed', - ], - ], - ], - ]; - - $request = $factory - ->createServerRequest('POST', 'https://mcp.example.com/mcp') - ->withBody($factory->createStream(json_encode($payload, \JSON_THROW_ON_ERROR))) - ->withAttribute('oauth.subject', 'trusted-user') - ->withAttribute('oauth.scopes', ['mcp.read']); - - $response = $middleware->process($request, $this->createEchoHandler($factory)); - $decoded = json_decode($response->getBody()->__toString(), true, 512, \JSON_THROW_ON_ERROR); - - $this->assertSame('trace-1', $decoded['params']['_meta']['trace_id']); - $this->assertSame('web', $decoded['params']['_meta']['oauth']['client_hint']); - $this->assertSame('trusted-user', $decoded['params']['_meta']['oauth']['oauth.subject']); - $this->assertSame(['mcp.read'], $decoded['params']['_meta']['oauth']['oauth.scopes']); - } - - #[TestDox('oauth request attributes are copied for each batch entry')] - public function testInjectsOauthAttributesIntoBatchRequest(): void - { - $factory = new Psr17Factory(); - $middleware = new OAuthRequestMetaMiddleware($factory); - - $payload = [ - [ - 'jsonrpc' => '2.0', - 'id' => 1, - 'method' => 'initialize', - 'params' => [], - ], - [ - 'jsonrpc' => '2.0', - 'id' => 2, - 'method' => 'tools/list', - ], - ]; - - $request = $factory - ->createServerRequest('POST', 'https://mcp.example.com/mcp') - ->withBody($factory->createStream(json_encode($payload, \JSON_THROW_ON_ERROR))) - ->withAttribute('oauth.subject', 'batch-user'); - - $response = $middleware->process($request, $this->createEchoHandler($factory)); - $decoded = json_decode($response->getBody()->__toString(), true, 512, \JSON_THROW_ON_ERROR); - - $this->assertSame('batch-user', $decoded[0]['params']['_meta']['oauth']['oauth.subject']); - $this->assertSame('batch-user', $decoded[1]['params']['_meta']['oauth']['oauth.subject']); - } - - #[TestDox('request without oauth attributes passes through unchanged')] - public function testNoOauthAttributesPassThrough(): void - { - $factory = new Psr17Factory(); - $middleware = new OAuthRequestMetaMiddleware($factory); - - $body = '{"jsonrpc":"2.0","id":1,"method":"ping","params":{}}'; - - $request = $factory - ->createServerRequest('POST', 'https://mcp.example.com/mcp') - ->withBody($factory->createStream($body)); - - $response = $middleware->process($request, $this->createEchoHandler($factory)); - - $this->assertSame($body, $response->getBody()->__toString()); - } - - #[TestDox('non post requests pass through unchanged')] - public function testNonPostPassesThrough(): void - { - $factory = new Psr17Factory(); - $middleware = new OAuthRequestMetaMiddleware($factory); - - $body = '{"jsonrpc":"2.0","id":1,"method":"ping"}'; - - $request = $factory - ->createServerRequest('GET', 'https://mcp.example.com/mcp') - ->withBody($factory->createStream($body)) - ->withAttribute('oauth.subject', 'user-1'); - - $response = $middleware->process($request, $this->createEchoHandler($factory)); - - $this->assertSame($body, $response->getBody()->__toString()); - } - - private function createEchoHandler(Psr17Factory $factory): RequestHandlerInterface - { - return new class($factory) implements RequestHandlerInterface { - public function __construct(private readonly Psr17Factory $factory) - { - } - - public function handle(ServerRequestInterface $request): ResponseInterface - { - return $this->factory - ->createResponse(200) - ->withBody($this->factory->createStream($request->getBody()->__toString())); - } - }; - } -} diff --git a/tests/Unit/Server/Transport/Http/Middleware/ProtectedResourceMetadataMiddlewareTest.php b/tests/Unit/Server/Transport/Http/Middleware/ProtectedResourceMetadataMiddlewareTest.php deleted file mode 100644 index 66e57e04..00000000 --- a/tests/Unit/Server/Transport/Http/Middleware/ProtectedResourceMetadataMiddlewareTest.php +++ /dev/null @@ -1,125 +0,0 @@ - - */ -class ProtectedResourceMetadataMiddlewareTest extends TestCase -{ - #[TestDox('default metadata endpoint returns protected resource metadata JSON')] - public function testDefaultMetadataEndpointReturnsJson(): void - { - $factory = new Psr17Factory(); - - $metadata = new ProtectedResourceMetadata( - authorizationServers: ['https://auth.example.com'], - scopesSupported: ['mcp:read', 'mcp:write'], - resource: 'https://mcp.example.com/mcp', - resourceName: 'Example MCP API', - resourceDocumentation: 'https://mcp.example.com/docs', - localizedHumanReadable: [ - 'resource_name#uk' => 'Pryklad MCP API', - ], - ); - - $middleware = new ProtectedResourceMetadataMiddleware( - metadata: $metadata, - responseFactory: $factory, - streamFactory: $factory, - ); - - $request = $factory->createServerRequest( - 'GET', - 'https://mcp.example.com/.well-known/oauth-protected-resource', - ); - - $handler = new class($factory) implements RequestHandlerInterface { - public function __construct(private ResponseFactoryInterface $factory) - { - } - - public function handle(ServerRequestInterface $request): ResponseInterface - { - return $this->factory->createResponse(404); - } - }; - - $response = $middleware->process($request, $handler); - - $this->assertSame(200, $response->getStatusCode()); - $this->assertSame('application/json', $response->getHeaderLine('Content-Type')); - - $payload = json_decode($response->getBody()->__toString(), true, 512, \JSON_THROW_ON_ERROR); - $this->assertSame(['https://auth.example.com'], $payload['authorization_servers']); - $this->assertSame(['mcp:read', 'mcp:write'], $payload['scopes_supported']); - $this->assertSame('https://mcp.example.com/mcp', $payload['resource']); - $this->assertSame('Example MCP API', $payload['resource_name']); - $this->assertSame('https://mcp.example.com/docs', $payload['resource_documentation']); - $this->assertSame('Pryklad MCP API', $payload['resource_name#uk']); - } - - #[TestDox('non metadata request passes to next middleware')] - public function testNonMetadataRequestPassesThrough(): void - { - $factory = new Psr17Factory(); - - $metadata = new ProtectedResourceMetadata( - authorizationServers: ['https://auth.example.com'], - ); - - $middleware = new ProtectedResourceMetadataMiddleware( - metadata: $metadata, - responseFactory: $factory, - streamFactory: $factory, - ); - - $request = $factory->createServerRequest('GET', 'https://mcp.example.com/mcp'); - - $handler = new class($factory) implements RequestHandlerInterface { - public function __construct(private ResponseFactoryInterface $factory) - { - } - - public function handle(ServerRequestInterface $request): ResponseInterface - { - return $this->factory->createResponse(204); - } - }; - - $response = $middleware->process($request, $handler); - - $this->assertSame(204, $response->getStatusCode()); - } - - #[TestDox('empty authorization servers are rejected')] - public function testEmptyAuthorizationServersThrows(): void - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('requires at least one authorization server'); - - new ProtectedResourceMetadata([]); - } -} diff --git a/tests/Unit/Server/Transport/Http/Middleware/ProtocolVersionMiddlewareTest.php b/tests/Unit/Server/Transport/Http/Middleware/ProtocolVersionMiddlewareTest.php deleted file mode 100644 index 143b4320..00000000 --- a/tests/Unit/Server/Transport/Http/Middleware/ProtocolVersionMiddlewareTest.php +++ /dev/null @@ -1,119 +0,0 @@ -factory, streamFactory: $this->factory); - $request = $this->factory->createServerRequest('POST', 'http://localhost/'); - - $response = $middleware->process($request, $this->passthroughHandler); - - $this->assertSame(200, $response->getStatusCode()); - } - - #[TestDox('rejects missing header when 2025-03-26 backwards-compat default is not in supportedVersions')] - public function testMissingHeaderRejectedByStrictServer(): void - { - $middleware = new ProtocolVersionMiddleware( - supportedVersions: [ProtocolVersion::V2025_11_25], - responseFactory: $this->factory, - streamFactory: $this->factory, - ); - $request = $this->factory->createServerRequest('POST', 'http://localhost/'); - - $response = $middleware->process($request, $this->passthroughHandler); - - $this->assertSame(400, $response->getStatusCode()); - } - - #[TestDox('accepts every handshake-era version by default')] - public function testAcceptsSupportedVersions(): void - { - $middleware = new ProtocolVersionMiddleware(responseFactory: $this->factory, streamFactory: $this->factory); - - foreach (ProtocolVersion::handshakeVersions() as $version) { - $request = $this->factory->createServerRequest('POST', 'http://localhost/') - ->withHeader(StreamableHttpTransport::PROTOCOL_VERSION_HEADER, $version->value); - - $response = $middleware->process($request, $this->passthroughHandler); - - $this->assertSame(200, $response->getStatusCode(), 'Expected '.$version->value.' to be accepted.'); - } - } - - #[TestDox('rejects modern-era versions by default, since the server cannot serve them yet')] - public function testRejectsModernVersionsByDefault(): void - { - $middleware = new ProtocolVersionMiddleware(responseFactory: $this->factory, streamFactory: $this->factory); - - foreach (ProtocolVersion::modernVersions() as $version) { - $request = $this->factory->createServerRequest('POST', 'http://localhost/') - ->withHeader(StreamableHttpTransport::PROTOCOL_VERSION_HEADER, $version->value); - - $response = $middleware->process($request, $this->passthroughHandler); - - $this->assertSame(400, $response->getStatusCode(), 'Expected '.$version->value.' to be rejected.'); - } - } - - #[TestDox('rejects unsupported well-formed version with 400')] - public function testRejectsUnsupportedVersion(): void - { - $middleware = new ProtocolVersionMiddleware(responseFactory: $this->factory, streamFactory: $this->factory); - $request = $this->factory->createServerRequest('POST', 'http://localhost/') - ->withHeader(StreamableHttpTransport::PROTOCOL_VERSION_HEADER, '1900-01-01'); - - $response = $middleware->process($request, $this->passthroughHandler); - - $this->assertSame(400, $response->getStatusCode()); - $this->assertSame('application/json', $response->getHeaderLine('Content-Type')); - } - - #[TestDox('rejects malformed version with 400')] - public function testRejectsMalformedVersion(): void - { - $middleware = new ProtocolVersionMiddleware(responseFactory: $this->factory, streamFactory: $this->factory); - $request = $this->factory->createServerRequest('POST', 'http://localhost/') - ->withHeader(StreamableHttpTransport::PROTOCOL_VERSION_HEADER, 'not-a-version'); - - $response = $middleware->process($request, $this->passthroughHandler); - - $this->assertSame(400, $response->getStatusCode()); - } - - #[TestDox('accepts only the supportedVersions whitelist when provided')] - public function testRestrictedSupportedVersions(): void - { - $middleware = new ProtocolVersionMiddleware( - supportedVersions: [ProtocolVersion::V2025_11_25], - responseFactory: $this->factory, - streamFactory: $this->factory, - ); - - $accepted = $this->factory->createServerRequest('POST', 'http://localhost/') - ->withHeader(StreamableHttpTransport::PROTOCOL_VERSION_HEADER, ProtocolVersion::V2025_11_25->value); - $rejected = $this->factory->createServerRequest('POST', 'http://localhost/') - ->withHeader(StreamableHttpTransport::PROTOCOL_VERSION_HEADER, ProtocolVersion::V2024_11_05->value); - - $this->assertSame(200, $middleware->process($accepted, $this->passthroughHandler)->getStatusCode()); - $this->assertSame(400, $middleware->process($rejected, $this->passthroughHandler)->getStatusCode()); - } -} diff --git a/tests/Unit/Server/Transport/Http/OAuth/JwksProviderTest.php b/tests/Unit/Server/Transport/Http/OAuth/JwksProviderTest.php deleted file mode 100644 index 78c1572e..00000000 --- a/tests/Unit/Server/Transport/Http/OAuth/JwksProviderTest.php +++ /dev/null @@ -1,168 +0,0 @@ - - */ -class JwksProviderTest extends TestCase -{ - #[TestDox('JWKS are loaded from explicit URI')] - public function testGetJwksFromExplicitUri(): void - { - $factory = new Psr17Factory(); - $jwksUri = 'https://auth.example.com/jwks'; - $jwks = [ - 'keys' => [ - ['kty' => 'RSA', 'kid' => 'kid-1', 'n' => 'abc', 'e' => 'AQAB'], - ], - ]; - - $httpClient = $this->createMock(ClientInterface::class); - $httpClient->expects($this->once()) - ->method('sendRequest') - ->willReturn( - $factory->createResponse(200)->withBody( - $factory->createStream(json_encode($jwks, \JSON_THROW_ON_ERROR)), - ), - ); - - $provider = new JwksProvider( - discovery: $this->createDiscoveryStub(), - httpClient: $httpClient, - requestFactory: $factory, - ); - - $result = $provider->getJwks('https://auth.example.com', $jwksUri); - - $this->assertSame($jwks, $result); - } - - #[TestDox('invalid cached JWKS are ignored and replaced by fetched values')] - public function testInvalidCachedJwksAreIgnored(): void - { - $factory = new Psr17Factory(); - $jwksUri = 'https://auth.example.com/jwks'; - $jwks = [ - 'keys' => [ - ['kty' => 'RSA', 'kid' => 'kid-1', 'n' => 'abc', 'e' => 'AQAB'], - ], - ]; - - $httpClient = $this->createMock(ClientInterface::class); - $httpClient->expects($this->once()) - ->method('sendRequest') - ->willReturn( - $factory->createResponse(200)->withBody( - $factory->createStream(json_encode($jwks, \JSON_THROW_ON_ERROR)), - ), - ); - - $cache = $this->createMock(CacheInterface::class); - $cache->expects($this->once()) - ->method('get') - ->willReturn(['keys' => []]); - $cache->expects($this->once()) - ->method('set'); - - $provider = new JwksProvider( - discovery: $this->createDiscoveryStub(), - httpClient: $httpClient, - requestFactory: $factory, - cache: $cache, - ); - - $result = $provider->getJwks('https://auth.example.com', $jwksUri); - - $this->assertSame($jwks, $result); - } - - #[TestDox('discovery is used when explicit JWKS URI is not provided')] - public function testDiscoveryIsUsedWhenUriIsMissing(): void - { - $factory = new Psr17Factory(); - $jwksUri = 'https://auth.example.com/jwks'; - $jwks = [ - 'keys' => [ - ['kty' => 'RSA', 'kid' => 'kid-1', 'n' => 'abc', 'e' => 'AQAB'], - ], - ]; - - $discovery = $this->createMock(OidcDiscoveryInterface::class); - $discovery->expects($this->once()) - ->method('getJwksUri') - ->with('https://auth.example.com') - ->willReturn($jwksUri); - - $httpClient = $this->createMock(ClientInterface::class); - $httpClient->expects($this->once()) - ->method('sendRequest') - ->willReturn( - $factory->createResponse(200)->withBody( - $factory->createStream(json_encode($jwks, \JSON_THROW_ON_ERROR)), - ), - ); - - $provider = new JwksProvider( - httpClient: $httpClient, - requestFactory: $factory, - discovery: $discovery, - ); - - $result = $provider->getJwks('https://auth.example.com'); - - $this->assertSame($jwks, $result); - } - - #[TestDox('empty keys in fetched JWKS throw RuntimeException')] - public function testEmptyKeysThrow(): void - { - $factory = new Psr17Factory(); - $jwksUri = 'https://auth.example.com/jwks'; - - $httpClient = $this->createMock(ClientInterface::class); - $httpClient->expects($this->once()) - ->method('sendRequest') - ->willReturn( - $factory->createResponse(200)->withBody( - $factory->createStream(json_encode(['keys' => []], \JSON_THROW_ON_ERROR)), - ), - ); - - $provider = new JwksProvider( - discovery: $this->createDiscoveryStub(), - httpClient: $httpClient, - requestFactory: $factory, - ); - - $this->expectException(RuntimeException::class); - $this->expectExceptionMessage('expected non-empty "keys" array'); - - $provider->getJwks('https://auth.example.com', $jwksUri); - } - - private function createDiscoveryStub(): OidcDiscoveryInterface - { - return $this->createStub(OidcDiscoveryInterface::class); - } -} diff --git a/tests/Unit/Server/Transport/Http/OAuth/JwtTokenValidatorTest.php b/tests/Unit/Server/Transport/Http/OAuth/JwtTokenValidatorTest.php deleted file mode 100644 index b6e518ac..00000000 --- a/tests/Unit/Server/Transport/Http/OAuth/JwtTokenValidatorTest.php +++ /dev/null @@ -1,589 +0,0 @@ - - */ -class JwtTokenValidatorTest extends TestCase -{ - #[TestDox('valid JWT is allowed and claims/scopes are exposed as request attributes')] - public function testValidJwtAllowsAndExposesAttributes(): void - { - $factory = new Psr17Factory(); - [$privateKeyPem, $publicJwk] = $this->generateRsaKeypairAsJwk('test-kid'); - - $jwksUri = 'https://auth.example.com/.well-known/jwks.json'; - $httpClient = $this->createHttpClientMock([ - $factory->createResponse(200) - ->withHeader('Content-Type', 'application/json') - ->withBody($factory->createStream(json_encode(['keys' => [$publicJwk]], \JSON_THROW_ON_ERROR))), - ]); - - $validator = new JwtTokenValidator( - issuer: 'https://auth.example.com', - audience: 'mcp-api', - jwksUri: $jwksUri, - jwksProvider: new JwksProvider(discovery: $this->createDiscoveryStub(), httpClient: $httpClient, requestFactory: $factory), - ); - - $token = JWT::encode( - [ - 'iss' => 'https://auth.example.com', - 'aud' => 'mcp-api', - 'sub' => 'user-123', - 'client_id' => 'client-abc', - 'azp' => 'client-abc', - 'scope' => 'mcp:read mcp:write', - 'iat' => time() - 10, - 'exp' => time() + 600, - ], - $privateKeyPem, - 'RS256', - keyId: 'test-kid', - ); - - $result = $validator->validate($token); - - $this->assertTrue($result->isAllowed()); - $attributes = $result->getAttributes(); - - $this->assertArrayHasKey('oauth.claims', $attributes); - $this->assertArrayHasKey('oauth.scopes', $attributes); - $this->assertSame(['mcp:read', 'mcp:write'], $attributes['oauth.scopes']); - $this->assertSame('user-123', $attributes['oauth.subject']); - $this->assertSame('client-abc', $attributes['oauth.client_id']); - $this->assertSame('client-abc', $attributes['oauth.authorized_party']); - } - - #[TestDox('issuer mismatch yields unauthorized result')] - public function testIssuerMismatchIsUnauthorized(): void - { - $factory = new Psr17Factory(); - [$privateKeyPem, $publicJwk] = $this->generateRsaKeypairAsJwk('test-kid'); - - $jwksUri = 'https://auth.example.com/.well-known/jwks.json'; - $httpClient = $this->createHttpClientMock([ - $factory->createResponse(200) - ->withHeader('Content-Type', 'application/json') - ->withBody($factory->createStream(json_encode(['keys' => [$publicJwk]], \JSON_THROW_ON_ERROR))), - ]); - - $validator = new JwtTokenValidator( - issuer: 'https://auth.example.com', - audience: 'mcp-api', - jwksUri: $jwksUri, - jwksProvider: new JwksProvider(discovery: $this->createDiscoveryStub(), httpClient: $httpClient, requestFactory: $factory), - ); - - $token = JWT::encode( - [ - 'iss' => 'https://other-issuer.example.com', - 'aud' => 'mcp-api', - 'sub' => 'user-123', - 'scope' => 'mcp:read', - 'iat' => time() - 10, - 'exp' => time() + 600, - ], - $privateKeyPem, - 'RS256', - keyId: 'test-kid', - ); - - $result = $validator->validate($token); - - $this->assertFalse($result->isAllowed()); - $this->assertSame(401, $result->getStatusCode()); - $this->assertSame('invalid_token', $result->getError()); - $this->assertSame('Token issuer mismatch.', $result->getErrorDescription()); - } - - #[TestDox('audience mismatch yields unauthorized result')] - public function testAudienceMismatchIsUnauthorized(): void - { - $factory = new Psr17Factory(); - [$privateKeyPem, $publicJwk] = $this->generateRsaKeypairAsJwk('test-kid'); - - $jwksUri = 'https://auth.example.com/.well-known/jwks.json'; - $httpClient = $this->createHttpClientMock([ - $factory->createResponse(200) - ->withHeader('Content-Type', 'application/json') - ->withBody($factory->createStream(json_encode(['keys' => [$publicJwk]], \JSON_THROW_ON_ERROR))), - ]); - - $validator = new JwtTokenValidator( - issuer: 'https://auth.example.com', - audience: ['mcp-api'], - jwksUri: $jwksUri, - jwksProvider: new JwksProvider(discovery: $this->createDiscoveryStub(), httpClient: $httpClient, requestFactory: $factory), - ); - - $token = JWT::encode( - [ - 'iss' => 'https://auth.example.com', - 'aud' => 'different-aud', - 'sub' => 'user-123', - 'scope' => 'mcp:read', - 'iat' => time() - 10, - 'exp' => time() + 600, - ], - $privateKeyPem, - 'RS256', - keyId: 'test-kid', - ); - - $result = $validator->validate($token); - - $this->assertFalse($result->isAllowed()); - $this->assertSame(401, $result->getStatusCode()); - $this->assertSame('invalid_token', $result->getError()); - $this->assertSame('Token audience mismatch.', $result->getErrorDescription()); - } - - #[TestDox('expired token yields unauthorized invalid_token with expired message')] - public function testExpiredTokenIsUnauthorized(): void - { - $factory = new Psr17Factory(); - [$privateKeyPem, $publicJwk] = $this->generateRsaKeypairAsJwk('test-kid'); - - $httpClient = $this->createHttpClientMock([ - $factory->createResponse(200) - ->withHeader('Content-Type', 'application/json') - ->withBody($factory->createStream(json_encode(['keys' => [$publicJwk]], \JSON_THROW_ON_ERROR))), - ]); - - $validator = new JwtTokenValidator( - issuer: 'https://auth.example.com', - audience: 'mcp-api', - jwksUri: 'https://auth.example.com/jwks', - jwksProvider: new JwksProvider(discovery: $this->createDiscoveryStub(), httpClient: $httpClient, requestFactory: $factory), - ); - - $token = JWT::encode( - [ - 'iss' => 'https://auth.example.com', - 'aud' => 'mcp-api', - 'sub' => 'user-123', - 'iat' => time() - 7200, - 'exp' => time() - 10, - ], - $privateKeyPem, - 'RS256', - keyId: 'test-kid', - ); - - $result = $validator->validate($token); - - $this->assertFalse($result->isAllowed()); - $this->assertSame(401, $result->getStatusCode()); - $this->assertSame('invalid_token', $result->getError()); - $this->assertSame('Token has expired.', $result->getErrorDescription()); - } - - #[TestDox('token with future nbf yields unauthorized invalid_token with not-yet-valid message')] - public function testBeforeValidTokenIsUnauthorized(): void - { - $factory = new Psr17Factory(); - [$privateKeyPem, $publicJwk] = $this->generateRsaKeypairAsJwk('test-kid'); - - $httpClient = $this->createHttpClientMock([ - $factory->createResponse(200) - ->withHeader('Content-Type', 'application/json') - ->withBody($factory->createStream(json_encode(['keys' => [$publicJwk]], \JSON_THROW_ON_ERROR))), - ]); - - $validator = new JwtTokenValidator( - issuer: 'https://auth.example.com', - audience: 'mcp-api', - jwksUri: 'https://auth.example.com/jwks', - jwksProvider: new JwksProvider(discovery: $this->createDiscoveryStub(), httpClient: $httpClient, requestFactory: $factory), - ); - - $token = JWT::encode( - [ - 'iss' => 'https://auth.example.com', - 'aud' => 'mcp-api', - 'sub' => 'user-123', - 'iat' => time(), - 'nbf' => time() + 3600, - 'exp' => time() + 7200, - ], - $privateKeyPem, - 'RS256', - keyId: 'test-kid', - ); - - $result = $validator->validate($token); - - $this->assertFalse($result->isAllowed()); - $this->assertSame(401, $result->getStatusCode()); - $this->assertSame('invalid_token', $result->getError()); - $this->assertSame('Token is not yet valid.', $result->getErrorDescription()); - } - - #[TestDox('signature verification failure yields unauthorized invalid_token with signature message')] - public function testSignatureInvalidIsUnauthorized(): void - { - $factory = new Psr17Factory(); - [$privateKeyPem, $publicJwk] = $this->generateRsaKeypairAsJwk('test-kid'); - - // Create a mismatched JWK with the same kid so the key lookup succeeds but signature verification fails. - [, $mismatchedJwk] = $this->generateRsaKeypairAsJwk('test-kid'); - $mismatchedJwk['kid'] = $publicJwk['kid']; - - $httpClient = $this->createHttpClientMock([ - $factory->createResponse(200) - ->withHeader('Content-Type', 'application/json') - ->withBody($factory->createStream(json_encode(['keys' => [$mismatchedJwk]], \JSON_THROW_ON_ERROR))), - ]); - - $validator = new JwtTokenValidator( - issuer: 'https://auth.example.com', - audience: 'mcp-api', - jwksUri: 'https://auth.example.com/jwks', - jwksProvider: new JwksProvider(discovery: $this->createDiscoveryStub(), httpClient: $httpClient, requestFactory: $factory), - ); - - $token = JWT::encode( - [ - 'iss' => 'https://auth.example.com', - 'aud' => 'mcp-api', - 'sub' => 'user-123', - 'iat' => time() - 10, - 'exp' => time() + 600, - ], - $privateKeyPem, - 'RS256', - keyId: 'test-kid', - ); - - $result = $validator->validate($token); - - $this->assertFalse($result->isAllowed()); - $this->assertSame(401, $result->getStatusCode()); - $this->assertSame('invalid_token', $result->getError()); - $this->assertSame('Token signature verification failed.', $result->getErrorDescription()); - } - - #[TestDox('JWKS HTTP error results in RuntimeException')] - public function testJwksHttpErrorThrowsRuntimeException(): void - { - $factory = new Psr17Factory(); - - $validator = new JwtTokenValidator( - issuer: 'https://auth.example.com', - audience: 'mcp-api', - jwksUri: 'https://auth.example.com/jwks', - jwksProvider: new JwksProvider(discovery: $this->createDiscoveryStub(), httpClient: $this->createHttpClientMock([$factory->createResponse(500)]), requestFactory: $factory), - ); - - $token = $this->unsignedJwt(['iss' => 'https://auth.example.com', 'aud' => 'mcp-api']); - - $this->expectException(RuntimeException::class); - $validator->validate($token); - } - - #[TestDox('Invalid JWKS JSON results in RuntimeException')] - public function testInvalidJwksJsonThrowsRuntimeException(): void - { - $factory = new Psr17Factory(); - - $httpClient = $this->createHttpClientMock([ - $factory->createResponse(200) - ->withHeader('Content-Type', 'application/json') - ->withBody($factory->createStream('{not-json')), - ]); - - $validator = new JwtTokenValidator( - issuer: 'https://auth.example.com', - audience: 'mcp-api', - jwksUri: 'https://auth.example.com/jwks', - jwksProvider: new JwksProvider(discovery: $this->createDiscoveryStub(), httpClient: $httpClient, requestFactory: $factory), - ); - - $token = $this->unsignedJwt(['iss' => 'https://auth.example.com', 'aud' => 'mcp-api']); - - $this->expectException(RuntimeException::class); - $validator->validate($token); - } - - #[TestDox('JWKS without keys array results in RuntimeException')] - public function testJwksMissingKeysThrowsRuntimeException(): void - { - $factory = new Psr17Factory(); - - $httpClient = $this->createHttpClientMock([ - $factory->createResponse(200) - ->withHeader('Content-Type', 'application/json') - ->withBody($factory->createStream(json_encode(['nope' => []], \JSON_THROW_ON_ERROR))), - ]); - - $validator = new JwtTokenValidator( - issuer: 'https://auth.example.com', - audience: 'mcp-api', - jwksUri: 'https://auth.example.com/jwks', - jwksProvider: new JwksProvider(discovery: $this->createDiscoveryStub(), httpClient: $httpClient, requestFactory: $factory), - ); - - $token = $this->unsignedJwt(['iss' => 'https://auth.example.com', 'aud' => 'mcp-api']); - - $this->expectException(RuntimeException::class); - $validator->validate($token); - } - - #[TestDox('requireScopes returns forbidden when any required scope is missing')] - public function testRequireScopesForbiddenWhenMissing(): void - { - $factory = new Psr17Factory(); - [$privateKeyPem, $publicJwk] = $this->generateRsaKeypairAsJwk('test-kid'); - - $httpClient = $this->createHttpClientMock([ - $factory->createResponse(200) - ->withHeader('Content-Type', 'application/json') - ->withBody($factory->createStream(json_encode(['keys' => [$publicJwk]], \JSON_THROW_ON_ERROR))), - ]); - - $validator = new JwtTokenValidator( - issuer: 'https://auth.example.com', - audience: 'mcp-api', - jwksUri: 'https://auth.example.com/jwks', - jwksProvider: new JwksProvider(discovery: $this->createDiscoveryStub(), httpClient: $httpClient, requestFactory: $factory), - ); - - $token = JWT::encode( - [ - 'iss' => 'https://auth.example.com', - 'aud' => 'mcp-api', - 'sub' => 'user-123', - 'scope' => 'mcp:read', - 'iat' => time() - 10, - 'exp' => time() + 600, - ], - $privateKeyPem, - 'RS256', - keyId: 'test-kid', - ); - - $result = $validator->validate($token); - $this->assertTrue($result->isAllowed()); - - $scoped = $validator->requireScopes($result, ['mcp:read', 'mcp:write']); - $this->assertFalse($scoped->isAllowed()); - $this->assertSame(403, $scoped->getStatusCode()); - $this->assertSame('insufficient_scope', $scoped->getError()); - $this->assertSame(['mcp:read', 'mcp:write'], $scoped->getScopes()); - } - - #[TestDox('requireScopes passes through when all required scopes are present')] - public function testRequireScopesPassesWhenPresent(): void - { - $factory = new Psr17Factory(); - [$privateKeyPem, $publicJwk] = $this->generateRsaKeypairAsJwk('test-kid'); - - $httpClient = $this->createHttpClientMock([ - $factory->createResponse(200) - ->withHeader('Content-Type', 'application/json') - ->withBody($factory->createStream(json_encode(['keys' => [$publicJwk]], \JSON_THROW_ON_ERROR))), - ]); - - $validator = new JwtTokenValidator( - issuer: 'https://auth.example.com', - audience: 'mcp-api', - jwksUri: 'https://auth.example.com/jwks', - jwksProvider: new JwksProvider(discovery: $this->createDiscoveryStub(), httpClient: $httpClient, requestFactory: $factory), - ); - - $token = JWT::encode( - [ - 'iss' => 'https://auth.example.com', - 'aud' => 'mcp-api', - 'sub' => 'user-123', - 'scope' => ['mcp:read', 'mcp:write'], - 'iat' => time() - 10, - 'exp' => time() + 600, - ], - $privateKeyPem, - 'RS256', - keyId: 'test-kid', - ); - - $result = $validator->validate($token); - $this->assertTrue($result->isAllowed()); - - $scoped = $validator->requireScopes($result, ['mcp:read']); - $this->assertTrue($scoped->isAllowed()); - } - - #[TestDox('extractScopes returns empty array when scope claim is missing or invalid type')] - public function testExtractScopesEdgeCases(): void - { - $factory = new Psr17Factory(); - [$privateKeyPem, $publicJwk] = $this->generateRsaKeypairAsJwk('test-kid'); - - $jwksResponse = $factory->createResponse(200) - ->withHeader('Content-Type', 'application/json') - ->withBody($factory->createStream(json_encode(['keys' => [$publicJwk]], \JSON_THROW_ON_ERROR))); - - $httpClient = $this->createHttpClientMock([$jwksResponse]); - - // missing scope - $validatorMissing = new JwtTokenValidator( - issuer: 'https://auth.example.com', - audience: 'mcp-api', - jwksUri: 'https://auth.example.com/jwks', - jwksProvider: new JwksProvider(discovery: $this->createDiscoveryStub(), httpClient: $httpClient, requestFactory: $factory), - ); - - $tokenMissing = JWT::encode( - [ - 'iss' => 'https://auth.example.com', - 'aud' => 'mcp-api', - 'sub' => 'user-123', - 'iat' => time() - 10, - 'exp' => time() + 600, - ], - $privateKeyPem, - 'RS256', - keyId: 'test-kid', - ); - - $resultMissing = $validatorMissing->validate($tokenMissing); - $this->assertTrue($resultMissing->isAllowed()); - $this->assertSame([], $resultMissing->getAttributes()['oauth.scopes']); - - // invalid scope type - $httpClient2 = $this->createHttpClientMock([$jwksResponse]); - - $validatorInvalid = new JwtTokenValidator( - issuer: 'https://auth.example.com', - audience: 'mcp-api', - jwksUri: 'https://auth.example.com/jwks', - jwksProvider: new JwksProvider(discovery: $this->createDiscoveryStub(), httpClient: $httpClient2, requestFactory: $factory), - ); - - $tokenInvalid = JWT::encode( - [ - 'iss' => 'https://auth.example.com', - 'aud' => 'mcp-api', - 'sub' => 'user-123', - 'scope' => 123, - 'iat' => time() - 10, - 'exp' => time() + 600, - ], - $privateKeyPem, - 'RS256', - keyId: 'test-kid', - ); - - $resultInvalid = $validatorInvalid->validate($tokenInvalid); - $this->assertTrue($resultInvalid->isAllowed()); - $this->assertSame([], $resultInvalid->getAttributes()['oauth.scopes']); - } - - private function unsignedJwt(array $claims): string - { - $header = $this->b64urlEncode(json_encode(['alg' => 'none', 'typ' => 'JWT'], \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); - - $httpClient = $this->createMock(ClientInterface::class); - $expectation = $httpClient - ->expects($this->exactly($expectedCalls)) - ->method('sendRequest') - ->with($this->isInstanceOf(RequestInterface::class)); - - if (1 === $expectedCalls) { - $expectation->willReturn($responses[0]); - } else { - // If expectedCalls > count(responses), keep returning the last response. - $sequence = $responses; - while (\count($sequence) < $expectedCalls) { - $sequence[] = $responses[array_key_last($responses)]; - } - $expectation->willReturnOnConsecutiveCalls(...$sequence); - } - - return $httpClient; - } -} diff --git a/tests/Unit/Server/Transport/Http/OAuth/LenientOidcDiscoveryMetadataPolicyTest.php b/tests/Unit/Server/Transport/Http/OAuth/LenientOidcDiscoveryMetadataPolicyTest.php deleted file mode 100644 index 2b67facb..00000000 --- a/tests/Unit/Server/Transport/Http/OAuth/LenientOidcDiscoveryMetadataPolicyTest.php +++ /dev/null @@ -1,125 +0,0 @@ - - */ -class LenientOidcDiscoveryMetadataPolicyTest extends TestCase -{ - #[TestDox('metadata without code challenge methods is valid (defaults to S256 downstream)')] - public function testMissingCodeChallengeMethodsIsValid(): void - { - $policy = new LenientOidcDiscoveryMetadataPolicy(); - $metadata = [ - 'authorization_endpoint' => 'https://auth.example.com/authorize', - 'token_endpoint' => 'https://auth.example.com/token', - 'jwks_uri' => 'https://auth.example.com/jwks', - ]; - - $this->assertTrue($policy->isValid($metadata)); - } - - #[TestDox('valid code challenge methods list is accepted')] - public function testValidCodeChallengeMethodsIsAccepted(): void - { - $policy = new LenientOidcDiscoveryMetadataPolicy(); - $metadata = [ - 'authorization_endpoint' => 'https://auth.example.com/authorize', - 'token_endpoint' => 'https://auth.example.com/token', - 'jwks_uri' => 'https://auth.example.com/jwks', - 'code_challenge_methods_supported' => ['S256'], - ]; - - $this->assertTrue($policy->isValid($metadata)); - } - - #[TestDox('empty code challenge methods list is invalid')] - public function testEmptyCodeChallengeMethodsIsInvalid(): void - { - $policy = new LenientOidcDiscoveryMetadataPolicy(); - $metadata = [ - 'authorization_endpoint' => 'https://auth.example.com/authorize', - 'token_endpoint' => 'https://auth.example.com/token', - 'jwks_uri' => 'https://auth.example.com/jwks', - 'code_challenge_methods_supported' => [], - ]; - - $this->assertFalse($policy->isValid($metadata)); - } - - #[TestDox('non string code challenge method is invalid')] - public function testNonStringCodeChallengeMethodIsInvalid(): void - { - $policy = new LenientOidcDiscoveryMetadataPolicy(); - $metadata = [ - 'authorization_endpoint' => 'https://auth.example.com/authorize', - 'token_endpoint' => 'https://auth.example.com/token', - 'jwks_uri' => 'https://auth.example.com/jwks', - 'code_challenge_methods_supported' => ['S256', 123], - ]; - - $this->assertFalse($policy->isValid($metadata)); - } - - #[TestDox('missing required fields is invalid')] - public function testMissingRequiredFieldsIsInvalid(): void - { - $policy = new LenientOidcDiscoveryMetadataPolicy(); - - $this->assertFalse($policy->isValid([ - 'authorization_endpoint' => 'https://auth.example.com/authorize', - 'token_endpoint' => 'https://auth.example.com/token', - // missing jwks_uri - ])); - } - - #[TestDox('empty string endpoint is invalid')] - public function testEmptyStringEndpointIsInvalid(): void - { - $policy = new LenientOidcDiscoveryMetadataPolicy(); - - $this->assertFalse($policy->isValid([ - 'authorization_endpoint' => '', - 'token_endpoint' => 'https://auth.example.com/token', - 'jwks_uri' => 'https://auth.example.com/jwks', - ])); - } - - #[TestDox('null code challenge methods is invalid')] - public function testNullCodeChallengeMethodsIsInvalid(): void - { - $policy = new LenientOidcDiscoveryMetadataPolicy(); - $metadata = [ - 'authorization_endpoint' => 'https://auth.example.com/authorize', - 'token_endpoint' => 'https://auth.example.com/token', - 'jwks_uri' => 'https://auth.example.com/jwks', - 'code_challenge_methods_supported' => null, - ]; - - $this->assertFalse($policy->isValid($metadata)); - } - - #[TestDox('non-array input is invalid')] - public function testNonArrayInputIsInvalid(): void - { - $policy = new LenientOidcDiscoveryMetadataPolicy(); - - $this->assertFalse($policy->isValid('not an array')); - } -} diff --git a/tests/Unit/Server/Transport/Http/OAuth/OidcDiscoveryTest.php b/tests/Unit/Server/Transport/Http/OAuth/OidcDiscoveryTest.php deleted file mode 100644 index 29c86d6f..00000000 --- a/tests/Unit/Server/Transport/Http/OAuth/OidcDiscoveryTest.php +++ /dev/null @@ -1,302 +0,0 @@ - - */ -class OidcDiscoveryTest extends TestCase -{ - #[TestDox('invalid issuer URL throws RuntimeException')] - public function testInvalidIssuerUrlThrows(): void - { - $this->skipIfPsrHttpClientIsMissing(); - - $factory = new Psr17Factory(); - $discovery = new OidcDiscovery( - httpClient: $this->createMock(ClientInterface::class), - requestFactory: $factory, - ); - - $this->expectException(RuntimeException::class); - $this->expectExceptionMessage('Invalid issuer URL'); - $discovery->discover('invalid-issuer'); - } - - #[TestDox('strict discovery rejects metadata without code challenge methods')] - public function testDiscoverRejectsMetadataWithoutCodeChallengeMethodsSupported(): void - { - $this->skipIfPsrHttpClientIsMissing(); - - $factory = new Psr17Factory(); - $issuer = 'https://auth.example.com'; - $metadata = [ - 'issuer' => $issuer, - 'authorization_endpoint' => 'https://auth.example.com/oauth2/v2.0/authorize', - 'token_endpoint' => 'https://auth.example.com/oauth2/v2.0/token', - 'jwks_uri' => 'https://auth.example.com/discovery/v2.0/keys', - ]; - - $httpClient = $this->createMock(ClientInterface::class); - $httpClient->expects($this->exactly(2)) - ->method('sendRequest') - ->willReturn($factory->createResponse(200)->withBody( - $factory->createStream(json_encode($metadata, \JSON_THROW_ON_ERROR)), - )); - - $discovery = new OidcDiscovery( - httpClient: $httpClient, - requestFactory: $factory, - ); - - $this->expectException(RuntimeException::class); - $this->expectExceptionMessage('Failed to discover authorization server metadata'); - $discovery->discover($issuer); - } - - #[TestDox('lenient discovery accepts metadata without code challenge methods')] - public function testDiscoverAcceptsMetadataWithoutCodeChallengeMethodsUsingLenientPolicy(): void - { - $this->skipIfPsrHttpClientIsMissing(); - - $factory = new Psr17Factory(); - $issuer = 'https://auth.example.com'; - $metadata = [ - 'issuer' => $issuer, - 'authorization_endpoint' => 'https://auth.example.com/oauth2/v2.0/authorize', - 'token_endpoint' => 'https://auth.example.com/oauth2/v2.0/token', - 'jwks_uri' => 'https://auth.example.com/discovery/v2.0/keys', - ]; - - $httpClient = $this->createMock(ClientInterface::class); - $httpClient->expects($this->once()) - ->method('sendRequest') - ->willReturn($factory->createResponse(200)->withBody( - $factory->createStream(json_encode($metadata, \JSON_THROW_ON_ERROR)), - )); - - $discovery = new OidcDiscovery( - httpClient: $httpClient, - requestFactory: $factory, - metadataPolicy: new LenientOidcDiscoveryMetadataPolicy(), - ); - - $result = $discovery->discover($issuer); - - $this->assertSame($metadata['authorization_endpoint'], $result['authorization_endpoint']); - $this->assertArrayNotHasKey('code_challenge_methods_supported', $result); - } - - #[TestDox('discover falls back to the next metadata URL when first response is invalid')] - public function testDiscoverFallsBackOnInvalidMetadataResponse(): void - { - $this->skipIfPsrHttpClientIsMissing(); - - $factory = new Psr17Factory(); - $requestedUrls = []; - - $invalidMetadata = [ - 'authorization_endpoint' => 'https://auth.example.com/oauth2/v2.0/authorize', - // token_endpoint is intentionally missing - 'jwks_uri' => 'https://auth.example.com/discovery/v2.0/keys', - ]; - $validMetadata = [ - 'issuer' => 'https://auth.example.com/tenant', - 'authorization_endpoint' => 'https://auth.example.com/oauth2/v2.0/authorize', - 'token_endpoint' => 'https://auth.example.com/oauth2/v2.0/token', - 'jwks_uri' => 'https://auth.example.com/discovery/v2.0/keys', - 'code_challenge_methods_supported' => ['S256'], - ]; - - $httpClient = $this->createMock(ClientInterface::class); - $httpClient->expects($this->exactly(2)) - ->method('sendRequest') - ->willReturnCallback(static function (RequestInterface $request) use ($factory, &$requestedUrls, $invalidMetadata, $validMetadata): ResponseInterface { - $requestedUrls[] = (string) $request->getUri(); - - $payload = 1 === \count($requestedUrls) ? $invalidMetadata : $validMetadata; - - return $factory->createResponse(200)->withBody( - $factory->createStream(json_encode($payload, \JSON_THROW_ON_ERROR)), - ); - }); - - $discovery = new OidcDiscovery( - httpClient: $httpClient, - requestFactory: $factory, - ); - - $metadata = $discovery->discover('https://auth.example.com/tenant'); - - $this->assertSame($validMetadata['authorization_endpoint'], $metadata['authorization_endpoint']); - $this->assertSame($validMetadata['token_endpoint'], $metadata['token_endpoint']); - $this->assertSame($validMetadata['jwks_uri'], $metadata['jwks_uri']); - $this->assertSame( - 'https://auth.example.com/.well-known/oauth-authorization-server/tenant', - $requestedUrls[0], - ); - $this->assertSame( - 'https://auth.example.com/.well-known/openid-configuration/tenant', - $requestedUrls[1], - ); - } - - #[TestDox('valid metadata from cache is returned without HTTP call')] - public function testDiscoverUsesValidCacheWithoutHttpCall(): void - { - $this->skipIfPsrHttpClientIsMissing(); - - $factory = new Psr17Factory(); - $cachedMetadata = [ - 'issuer' => 'https://auth.example.com/tenant', - 'authorization_endpoint' => 'https://auth.example.com/oauth2/v2.0/authorize', - 'token_endpoint' => 'https://auth.example.com/oauth2/v2.0/token', - 'jwks_uri' => 'https://auth.example.com/discovery/v2.0/keys', - 'code_challenge_methods_supported' => ['S256'], - ]; - - $httpClient = $this->createMock(ClientInterface::class); - $httpClient->expects($this->never())->method('sendRequest'); - - $cache = $this->createMock(CacheInterface::class); - $cache->expects($this->once()) - ->method('get') - ->willReturn($cachedMetadata); - $cache->expects($this->never())->method('set'); - - $discovery = new OidcDiscovery( - httpClient: $httpClient, - requestFactory: $factory, - cache: $cache, - ); - - $metadata = $discovery->discover('https://auth.example.com/tenant'); - - $this->assertSame($cachedMetadata, $metadata); - } - - #[TestDox('discover skips metadata when issuer claim does not match requested issuer')] - public function testDiscoverSkipsIssuerMismatch(): void - { - $this->skipIfPsrHttpClientIsMissing(); - - $factory = new Psr17Factory(); - $requestedUrls = []; - - $issuerMismatch = [ - 'issuer' => 'https://auth.example.com/other-tenant', - 'authorization_endpoint' => 'https://auth.example.com/oauth2/v2.0/authorize', - 'token_endpoint' => 'https://auth.example.com/oauth2/v2.0/token', - 'jwks_uri' => 'https://auth.example.com/discovery/v2.0/keys', - 'code_challenge_methods_supported' => ['S256'], - ]; - $validMetadata = [ - 'issuer' => 'https://auth.example.com/tenant', - 'authorization_endpoint' => 'https://auth.example.com/oauth2/v2.0/authorize', - 'token_endpoint' => 'https://auth.example.com/oauth2/v2.0/token', - 'jwks_uri' => 'https://auth.example.com/discovery/v2.0/keys', - 'code_challenge_methods_supported' => ['S256'], - ]; - - $httpClient = $this->createMock(ClientInterface::class); - $httpClient->expects($this->exactly(2)) - ->method('sendRequest') - ->willReturnCallback(static function (RequestInterface $request) use ($factory, &$requestedUrls, $issuerMismatch, $validMetadata): ResponseInterface { - $requestedUrls[] = (string) $request->getUri(); - - $payload = 1 === \count($requestedUrls) ? $issuerMismatch : $validMetadata; - - return $factory->createResponse(200)->withBody( - $factory->createStream(json_encode($payload, \JSON_THROW_ON_ERROR)), - ); - }); - - $discovery = new OidcDiscovery( - httpClient: $httpClient, - requestFactory: $factory, - ); - - $metadata = $discovery->discover('https://auth.example.com/tenant'); - - $this->assertSame($validMetadata['issuer'], $metadata['issuer']); - $this->assertSame( - 'https://auth.example.com/.well-known/oauth-authorization-server/tenant', - $requestedUrls[0], - ); - $this->assertSame( - 'https://auth.example.com/.well-known/openid-configuration/tenant', - $requestedUrls[1], - ); - } - - #[TestDox('issuer without path uses standard well-known endpoints')] - public function testIssuerWithoutPathUsesStandardWellKnownEndpoints(): void - { - $this->skipIfPsrHttpClientIsMissing(); - - $factory = new Psr17Factory(); - $requestedUrls = []; - $validMetadata = [ - 'issuer' => 'https://auth.example.com', - 'authorization_endpoint' => 'https://auth.example.com/oauth2/v2.0/authorize', - 'token_endpoint' => 'https://auth.example.com/oauth2/v2.0/token', - 'jwks_uri' => 'https://auth.example.com/discovery/v2.0/keys', - 'code_challenge_methods_supported' => ['S256'], - ]; - - $httpClient = $this->createMock(ClientInterface::class); - $httpClient->expects($this->exactly(2)) - ->method('sendRequest') - ->willReturnCallback(static function (RequestInterface $request) use ($factory, &$requestedUrls, $validMetadata): ResponseInterface { - $requestedUrls[] = (string) $request->getUri(); - - if (1 === \count($requestedUrls)) { - return $factory->createResponse(404); - } - - return $factory->createResponse(200)->withBody( - $factory->createStream(json_encode($validMetadata, \JSON_THROW_ON_ERROR)), - ); - }); - - $discovery = new OidcDiscovery( - httpClient: $httpClient, - requestFactory: $factory, - ); - - $metadata = $discovery->discover('https://auth.example.com'); - - $this->assertSame($validMetadata['jwks_uri'], $metadata['jwks_uri']); - $this->assertSame('https://auth.example.com/.well-known/oauth-authorization-server', $requestedUrls[0]); - $this->assertSame('https://auth.example.com/.well-known/openid-configuration', $requestedUrls[1]); - } - - private function skipIfPsrHttpClientIsMissing(): void - { - if (!interface_exists(ClientInterface::class)) { - $this->markTestSkipped('psr/http-client is not available in this runtime.'); - } - } -} diff --git a/tests/Unit/Server/Transport/Http/OAuth/ProtectedResourceMetadataHandlerTest.php b/tests/Unit/Server/Transport/Http/OAuth/ProtectedResourceMetadataHandlerTest.php deleted file mode 100644 index b751df4c..00000000 --- a/tests/Unit/Server/Transport/Http/OAuth/ProtectedResourceMetadataHandlerTest.php +++ /dev/null @@ -1,93 +0,0 @@ - - */ -class ProtectedResourceMetadataHandlerTest extends TestCase -{ - #[TestDox('handle returns protected resource metadata JSON')] - public function testHandleReturnsMetadataJson(): void - { - $factory = new Psr17Factory(); - - $metadata = new ProtectedResourceMetadata( - authorizationServers: ['https://auth.example.com'], - scopesSupported: ['mcp:read', 'mcp:write'], - resource: 'https://mcp.example.com/mcp', - resourceName: 'Example MCP API', - resourceDocumentation: 'https://mcp.example.com/docs', - localizedHumanReadable: [ - 'resource_name#uk' => 'Pryklad MCP API', - ], - ); - - $handler = new ProtectedResourceMetadataHandler( - metadata: $metadata, - responseFactory: $factory, - streamFactory: $factory, - ); - - $request = $factory->createServerRequest( - 'GET', - 'https://mcp.example.com/.well-known/oauth-protected-resource', - ); - - $response = $handler->handle($request); - - $this->assertSame(200, $response->getStatusCode()); - $this->assertSame('application/json', $response->getHeaderLine('Content-Type')); - - $payload = json_decode($response->getBody()->__toString(), true, 512, \JSON_THROW_ON_ERROR); - $this->assertSame(['https://auth.example.com'], $payload['authorization_servers']); - $this->assertSame(['mcp:read', 'mcp:write'], $payload['scopes_supported']); - $this->assertSame('https://mcp.example.com/mcp', $payload['resource']); - $this->assertSame('Example MCP API', $payload['resource_name']); - $this->assertSame('https://mcp.example.com/docs', $payload['resource_documentation']); - $this->assertSame('Pryklad MCP API', $payload['resource_name#uk']); - } - - #[TestDox('handle serves metadata regardless of request path or method')] - public function testHandleIgnoresRoutingConcerns(): void - { - $factory = new Psr17Factory(); - - $handler = new ProtectedResourceMetadataHandler( - metadata: new ProtectedResourceMetadata( - authorizationServers: ['https://auth.example.com'], - ), - responseFactory: $factory, - streamFactory: $factory, - ); - - // The handler is the "controller action" — the caller (middleware or framework - // router) owns path/method matching, so an arbitrary request still yields metadata. - $request = $factory->createServerRequest('POST', 'https://mcp.example.com/anything'); - - $response = $handler->handle($request); - - $this->assertSame(200, $response->getStatusCode()); - $this->assertSame('application/json', $response->getHeaderLine('Content-Type')); - - $payload = json_decode($response->getBody()->__toString(), true, 512, \JSON_THROW_ON_ERROR); - $this->assertSame(['https://auth.example.com'], $payload['authorization_servers']); - } -} diff --git a/tests/Unit/Server/Transport/Http/OAuth/ProtectedResourceMetadataTest.php b/tests/Unit/Server/Transport/Http/OAuth/ProtectedResourceMetadataTest.php deleted file mode 100644 index fbb51a91..00000000 --- a/tests/Unit/Server/Transport/Http/OAuth/ProtectedResourceMetadataTest.php +++ /dev/null @@ -1,86 +0,0 @@ - - */ -class ProtectedResourceMetadataTest extends TestCase -{ - #[TestDox('serializes RFC 9728 metadata including human-readable fields')] - public function testJsonSerializeIncludesHumanReadableFields(): void - { - $metadata = new ProtectedResourceMetadata( - authorizationServers: ['https://auth.example.com'], - scopesSupported: ['openid', 'profile'], - resource: 'https://api.example.com/mcp', - resourceName: 'Example MCP API', - resourceDocumentation: 'https://api.example.com/docs', - resourcePolicyUri: 'https://api.example.com/policy', - resourceTosUri: 'https://api.example.com/tos', - localizedHumanReadable: [ - 'resource_name#en' => 'Example MCP API', - ], - extra: [ - 'bearer_methods_supported' => ['header'], - ], - metadataPaths: ['.well-known/oauth-protected-resource'], - ); - - $this->assertSame( - [ - 'bearer_methods_supported' => ['header'], - 'authorization_servers' => ['https://auth.example.com'], - 'scopes_supported' => ['openid', 'profile'], - 'resource' => 'https://api.example.com/mcp', - 'resource_name' => 'Example MCP API', - 'resource_documentation' => 'https://api.example.com/docs', - 'resource_policy_uri' => 'https://api.example.com/policy', - 'resource_tos_uri' => 'https://api.example.com/tos', - 'resource_name#en' => 'Example MCP API', - ], - $metadata->jsonSerialize(), - ); - $this->assertSame('/.well-known/oauth-protected-resource', $metadata->getPrimaryMetadataPath()); - $this->assertSame(['openid', 'profile'], $metadata->getScopesSupported()); - } - - #[TestDox('invalid localized human-readable field is rejected')] - public function testInvalidLocalizedHumanReadableFieldThrows(): void - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Invalid localized human-readable field'); - - new ProtectedResourceMetadata( - authorizationServers: ['https://auth.example.com'], - localizedHumanReadable: [ - 'invalid#en' => 'value', - ], - ); - } - - #[TestDox('empty authorization servers are rejected')] - public function testEmptyAuthorizationServersThrows(): void - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('requires at least one authorization server'); - - new ProtectedResourceMetadata([]); - } -} diff --git a/tests/Unit/Server/Transport/Http/OAuth/StrictOidcDiscoveryMetadataPolicyTest.php b/tests/Unit/Server/Transport/Http/OAuth/StrictOidcDiscoveryMetadataPolicyTest.php deleted file mode 100644 index 3d1e8a7d..00000000 --- a/tests/Unit/Server/Transport/Http/OAuth/StrictOidcDiscoveryMetadataPolicyTest.php +++ /dev/null @@ -1,79 +0,0 @@ - - */ -class StrictOidcDiscoveryMetadataPolicyTest extends TestCase -{ - #[TestDox('metadata without code challenge methods is invalid in strict mode')] - public function testMissingCodeChallengeMethodsIsInvalid(): void - { - $policy = new StrictOidcDiscoveryMetadataPolicy(); - $metadata = [ - 'authorization_endpoint' => 'https://auth.example.com/authorize', - 'token_endpoint' => 'https://auth.example.com/token', - 'jwks_uri' => 'https://auth.example.com/jwks', - ]; - - $this->assertFalse($policy->isValid($metadata)); - } - - #[TestDox('valid code challenge methods list is accepted in strict mode')] - public function testValidCodeChallengeMethodsIsAccepted(): void - { - $policy = new StrictOidcDiscoveryMetadataPolicy(); - $metadata = [ - 'authorization_endpoint' => 'https://auth.example.com/authorize', - 'token_endpoint' => 'https://auth.example.com/token', - 'jwks_uri' => 'https://auth.example.com/jwks', - 'code_challenge_methods_supported' => ['S256'], - ]; - - $this->assertTrue($policy->isValid($metadata)); - } - - #[TestDox('empty code challenge methods list is invalid in strict mode')] - public function testEmptyCodeChallengeMethodsIsInvalid(): void - { - $policy = new StrictOidcDiscoveryMetadataPolicy(); - $metadata = [ - 'authorization_endpoint' => 'https://auth.example.com/authorize', - 'token_endpoint' => 'https://auth.example.com/token', - 'jwks_uri' => 'https://auth.example.com/jwks', - 'code_challenge_methods_supported' => [], - ]; - - $this->assertFalse($policy->isValid($metadata)); - } - - #[TestDox('non string code challenge method is invalid in strict mode')] - public function testNonStringCodeChallengeMethodIsInvalid(): void - { - $policy = new StrictOidcDiscoveryMetadataPolicy(); - $metadata = [ - 'authorization_endpoint' => 'https://auth.example.com/authorize', - 'token_endpoint' => 'https://auth.example.com/token', - 'jwks_uri' => 'https://auth.example.com/jwks', - 'code_challenge_methods_supported' => ['S256', 123], - ]; - - $this->assertFalse($policy->isValid($metadata)); - } -} diff --git a/tests/Unit/Server/Transport/StdioTransportTest.php b/tests/Unit/Server/Transport/StdioTransportTest.php deleted file mode 100644 index dbfcec2f..00000000 --- a/tests/Unit/Server/Transport/StdioTransportTest.php +++ /dev/null @@ -1,101 +0,0 @@ -createTransport(str_repeat('a', 100)."\n", $messages, maxLineBytes: 16); - - $this->pumpToEof($transport); - - $this->assertSame([], $messages, 'the over-length line must never be dispatched'); - } - - #[TestDox('processing resumes with the next line after an over-length line is discarded')] - public function testRecoversAfterOverlongLine(): void - { - $messages = []; - $transport = $this->createTransport(str_repeat('a', 100)."\n".'{"valid":1}'."\n", $messages, maxLineBytes: 16); - - $this->pumpToEof($transport); - - $this->assertSame(['{"valid":1}'], $messages); - } - - #[TestDox('a normal line within the cap is dispatched')] - public function testNormalLineIsDispatched(): void - { - $messages = []; - $transport = $this->createTransport('{"jsonrpc":"2.0","id":1}'."\n", $messages); - - $this->pumpToEof($transport); - - $this->assertSame(['{"jsonrpc":"2.0","id":1}'], $messages); - } - - #[TestDox('the line byte cap must be a positive number of bytes')] - public function testRejectsNonPositiveCap(): void - { - $this->expectException(InvalidArgumentException::class); - - new StdioTransport(input: $this->stream(''), output: $this->stream(''), maxLineBytes: 0); - } - - /** - * @param list $messages - */ - private function createTransport(string $input, array &$messages, int $maxLineBytes = 4 * 1024 * 1024): StdioTransport - { - $transport = new StdioTransport( - input: $this->stream($input), - output: $this->stream(''), - maxLineBytes: $maxLineBytes, - ); - - $transport->onMessage(static function ($transport, string $payload) use (&$messages): void { - $messages[] = $payload; - }); - - return $transport; - } - - /** - * @return resource - */ - private function stream(string $contents) - { - $stream = fopen('php://temp', 'r+'); - fwrite($stream, $contents); - rewind($stream); - - return $stream; - } - - private function pumpToEof(StdioTransport $transport): void - { - $processInput = new \ReflectionMethod($transport, 'processInput'); - $input = (new \ReflectionProperty($transport, 'input'))->getValue($transport); - - for ($i = 0; $i < 1000 && !feof($input); ++$i) { - $processInput->invoke($transport); - } - } -} diff --git a/tests/Unit/Server/Transport/StreamableHttpTransportTest.php b/tests/Unit/Server/Transport/StreamableHttpTransportTest.php deleted file mode 100644 index 22b7fef2..00000000 --- a/tests/Unit/Server/Transport/StreamableHttpTransportTest.php +++ /dev/null @@ -1,346 +0,0 @@ -factory = new Psr17Factory(); - } - - #[TestDox('default middleware is applied when none is passed')] - public function testDefaultMiddlewareIsAppliedWhenOmitted(): void - { - // Preflight: OPTIONS + Access-Control-Request-Method — CorsMiddleware advertises Methods/Headers only on preflight. - $request = $this->factory - ->createServerRequest('OPTIONS', 'http://localhost/') - ->withHeader('Host', 'localhost') - ->withHeader('Access-Control-Request-Method', 'POST'); - - $transport = new StreamableHttpTransport($request, $this->factory, $this->factory); - - $response = $transport->listen(); - - $this->assertSame(204, $response->getStatusCode()); - $this->assertFalse($response->hasHeader('Access-Control-Allow-Origin')); // secure-by-default - $this->assertSame('GET, POST, DELETE', $response->getHeaderLine('Access-Control-Allow-Methods')); - $this->assertNotSame('', $response->getHeaderLine('Access-Control-Allow-Headers')); - $this->assertNotSame('', $response->getHeaderLine('Access-Control-Expose-Headers')); - } - - #[TestDox('default middleware blocks non-localhost Origin')] - public function testDefaultMiddlewareBlocksRebindingAttempt(): void - { - $request = $this->factory - ->createServerRequest('POST', 'http://localhost/') - ->withHeader('Host', 'localhost') - ->withHeader('Origin', 'http://evil.example.com'); - - $transport = new StreamableHttpTransport($request, $this->factory, $this->factory); - - $response = $transport->listen(); - - $this->assertSame(403, $response->getStatusCode()); - } - - #[TestDox('default middleware rejects unsupported MCP-Protocol-Version')] - public function testDefaultMiddlewareRejectsUnsupportedProtocolVersion(): void - { - $request = $this->factory - ->createServerRequest('POST', 'http://localhost/') - ->withHeader('Host', 'localhost') - ->withHeader(StreamableHttpTransport::PROTOCOL_VERSION_HEADER, '1900-01-01'); - - $transport = new StreamableHttpTransport($request, $this->factory, $this->factory); - - $response = $transport->listen(); - - $this->assertSame(400, $response->getStatusCode()); - } - - #[TestDox('malformed MCP session IDs are rejected as bad requests')] - public function testMalformedSessionIdHeaderReturnsBadRequest(): void - { - $request = $this->factory - ->createServerRequest('POST', 'http://localhost/') - ->withHeader('Host', 'localhost') - ->withHeader(StreamableHttpTransport::SESSION_HEADER, '{"not":"a-token"}'); - - $transport = new StreamableHttpTransport($request, $this->factory, $this->factory); - - $response = $transport->listen(); - - $this->assertSame(400, $response->getStatusCode()); - $this->assertStringContainsString(StreamableHttpTransport::SESSION_HEADER, (string) $response->getBody()); - } - - #[TestDox('duplicate MCP session ID headers are rejected as bad requests')] - public function testDuplicateSessionIdHeadersReturnBadRequest(): void - { - $request = $this->factory - ->createServerRequest('POST', 'http://localhost/') - ->withHeader('Host', 'localhost') - ->withHeader(StreamableHttpTransport::SESSION_HEADER, '2fb587fc-593f-47ce-9d9a-9c06f2b907a3') - ->withAddedHeader(StreamableHttpTransport::SESSION_HEADER, '5e583da8-a677-4446-b723-4ddbe00fda62'); - - $transport = new StreamableHttpTransport($request, $this->factory, $this->factory); - - $response = $transport->listen(); - - $this->assertSame(400, $response->getStatusCode()); - $this->assertStringContainsString('must not be repeated', (string) $response->getBody()); - } - - #[TestDox('explicit empty middleware list disables defaults and emits a warning log')] - public function testEmptyMiddlewareListDisablesDefaultsAndWarns(): void - { - $request = $this->factory - ->createServerRequest('POST', 'http://localhost/') - ->withHeader('Host', 'evil.example.com') - ->withHeader('Origin', 'http://evil.example.com'); - - $logger = $this->createMock(LoggerInterface::class); - $logger->expects($this->once()) - ->method('warning') - ->with($this->stringContains('empty middleware list')); - - $transport = new StreamableHttpTransport( - $request, - $this->factory, - $this->factory, - $logger, - [], - ); - - $response = $transport->listen(); - - // No CORS, no DNS rebinding check — transport just answers. - $this->assertNotSame(403, $response->getStatusCode()); - $this->assertFalse($response->hasHeader('Access-Control-Allow-Origin')); - $this->assertFalse($response->hasHeader('Access-Control-Allow-Methods')); - } - - #[TestDox('null middleware does not trigger the empty-list warning')] - public function testNullMiddlewareDoesNotWarn(): void - { - $request = $this->factory - ->createServerRequest('OPTIONS', 'http://localhost/') - ->withHeader('Host', 'localhost'); - - $logger = $this->createMock(LoggerInterface::class); - $logger->expects($this->never())->method('warning'); - - $transport = new StreamableHttpTransport($request, $this->factory, $this->factory, $logger); - $transport->listen(); - } - - #[TestDox('custom middleware composes with default stack via spread')] - public function testDefaultsCanBeSpreadAndExtended(): void - { - $request = $this->factory - ->createServerRequest('POST', 'http://localhost/') - ->withHeader('Host', 'localhost'); - - $transport = new StreamableHttpTransport( - $request, - $this->factory, - $this->factory, - null, - [ - ...StreamableHttpTransport::defaultMiddleware(), - $this->stubAuth401(), - ], - ); - - $response = $transport->listen(); - - $this->assertSame(401, $response->getStatusCode()); - // CORS middleware is outermost — Expose-Headers is emitted on all responses, including 401. - $this->assertSame('Mcp-Session-Id', $response->getHeaderLine('Access-Control-Expose-Headers')); - } - - #[TestDox('defaults can be filtered to drop DNS rebinding for proxy deployments')] - public function testDefaultsCanBeFilteredToDropDnsRebinding(): void - { - // Behind a reverse proxy: real Host is api.myapp.com, browser Origin is myapp.com. - // DnsRebindingProtectionMiddleware default (localhost-only) would 403 this — drop it. - $request = $this->factory - ->createServerRequest('POST', 'http://api.myapp.com/') - ->withHeader('Host', 'api.myapp.com') - ->withHeader('Origin', 'https://myapp.com'); - - $transport = new StreamableHttpTransport( - $request, - $this->factory, - $this->factory, - null, - [ - ...array_filter( - StreamableHttpTransport::defaultMiddleware(), - static fn (MiddlewareInterface $m): bool => !$m instanceof DnsRebindingProtectionMiddleware, - ), - $this->stubAuth401(), - ], - ); - - $response = $transport->listen(); - - // Auth short-circuits with 401 — proves DNS rebinding didn't reject the request first. - $this->assertSame(401, $response->getStatusCode()); - // CORS middleware is still in the chain — Expose-Headers attached to the 401. - $this->assertSame('Mcp-Session-Id', $response->getHeaderLine('Access-Control-Expose-Headers')); - } - - #[TestDox('configured CorsMiddleware reflects matching Origin')] - public function testConfiguredCorsReflectsMatchingOrigin(): void - { - $request = $this->factory - ->createServerRequest('POST', 'http://localhost/') - ->withHeader('Host', 'localhost') - ->withHeader('Origin', 'https://myapp.example.com'); - - $transport = new StreamableHttpTransport( - $request, - $this->factory, - $this->factory, - null, - [ - new CorsMiddleware(allowedOrigins: ['https://myapp.example.com']), - new DnsRebindingProtectionMiddleware(allowedHosts: ['localhost']), - new ProtocolVersionMiddleware(), - ], - ); - - $response = $transport->listen(); - - $this->assertSame('https://myapp.example.com', $response->getHeaderLine('Access-Control-Allow-Origin')); - } - - #[TestDox('middleware runs before transport handles the request')] - public function testMiddlewareRunsBeforeTransportHandlesRequest(): void - { - $request = $this->factory->createServerRequest('OPTIONS', 'http://localhost/') - ->withHeader('Host', 'localhost'); - - $state = new \stdClass(); - $state->called = false; - $spy = new class($state) implements MiddlewareInterface { - public function __construct(private \stdClass $state) - { - } - - public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface - { - $this->state->called = true; - - return $handler->handle($request); - } - }; - - $transport = new StreamableHttpTransport( - $request, - $this->factory, - $this->factory, - null, - [$spy], - ); - - $response = $transport->listen(); - - $this->assertTrue($state->called); - $this->assertSame(204, $response->getStatusCode()); - } - - #[TestDox('non-middleware entries are rejected')] - public function testInvalidMiddlewareEntryThrows(): void - { - $request = $this->factory->createServerRequest('POST', 'http://localhost/'); - - $this->expectException(InvalidArgumentException::class); - - new StreamableHttpTransport( - $request, - $this->factory, - $this->factory, - null, - [new \stdClass()], // @phpstan-ignore-line argument.type - ); - } - - public function testPostBodyExceedingMaxBytesReturns413(): void - { - $request = $this->factory - ->createServerRequest('POST', 'http://localhost/') - ->withBody($this->factory->createStream(str_repeat('a', 64))); - - // Empty middleware bypasses the default security stack to isolate body-size handling. - $transport = new StreamableHttpTransport($request, $this->factory, $this->factory, null, [], maxBodyBytes: 16); - - $response = $transport->listen(); - - $this->assertSame(413, $response->getStatusCode()); - } - - public function testPostBodyWithinMaxBytesIsNotRejected(): void - { - $request = $this->factory - ->createServerRequest('POST', 'http://localhost/') - ->withBody($this->factory->createStream('{}')); - - $transport = new StreamableHttpTransport($request, $this->factory, $this->factory, null, [], maxBodyBytes: 1024); - - $response = $transport->listen(); - - $this->assertNotSame(413, $response->getStatusCode()); - } - - public function testNonPositiveMaxBodyBytesThrows(): void - { - $request = $this->factory->createServerRequest('POST', 'http://localhost/'); - - $this->expectException(InvalidArgumentException::class); - - new StreamableHttpTransport($request, $this->factory, $this->factory, null, [], maxBodyBytes: 0); - } - - private function stubAuth401(): MiddlewareInterface - { - return new class($this->factory) implements MiddlewareInterface { - public function __construct(private ResponseFactoryInterface $factory) - { - } - - public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface - { - return $this->factory->createResponse(401); - } - }; - } -} diff --git a/tests/Unit/ServerTest.php b/tests/Unit/ServerTest.php deleted file mode 100644 index 373af4a3..00000000 --- a/tests/Unit/ServerTest.php +++ /dev/null @@ -1,157 +0,0 @@ - */ - private $transport; - - protected function setUp(): void - { - $this->protocol = $this->createMock(Protocol::class); - $this->transport = $this->createMock(TransportInterface::class); - } - - #[TestDox('builder() returns a Builder instance')] - public function testBuilderReturnsBuilderInstance(): void - { - $builder = Server::builder(); - - $this->assertInstanceOf(Builder::class, $builder); - } - - #[TestDox('run() orchestrates transport lifecycle and protocol connection')] - public function testRunOrchestatesTransportLifecycle(): void - { - $callOrder = []; - - $this->transport->expects($this->once()) - ->method('initialize') - ->willReturnCallback(static function () use (&$callOrder) { - $callOrder[] = 'initialize'; - }); - - $this->protocol->expects($this->once()) - ->method('connect') - ->with($this->transport) - ->willReturnCallback(static function () use (&$callOrder) { - $callOrder[] = 'connect'; - }); - - $this->transport->expects($this->once()) - ->method('listen') - ->willReturnCallback(static function () use (&$callOrder) { - $callOrder[] = 'listen'; - - return 0; - }); - - $this->transport->expects($this->once()) - ->method('close') - ->willReturnCallback(static function () use (&$callOrder) { - $callOrder[] = 'close'; - }); - - $server = new Server($this->protocol); - $result = $server->run($this->transport); - - $this->assertEquals([ - 'initialize', - 'connect', - 'listen', - 'close', - ], $callOrder); - - $this->assertEquals(0, $result); - } - - #[TestDox('run() closes transport even if listen() throws exception')] - public function testRunClosesTransportEvenOnException(): void - { - $this->transport->method('initialize'); - $this->protocol->method('connect'); - - $this->transport->expects($this->once()) - ->method('listen') - ->willThrowException(new \RuntimeException('Transport error')); - - // close() should still be called even though listen() threw - $this->transport->expects($this->once())->method('close'); - - $server = new Server($this->protocol); - - $this->expectException(\RuntimeException::class); - $this->expectExceptionMessage('Transport error'); - - $server->run($this->transport); - } - - #[TestDox('run() propagates exception if initialize() throws')] - public function testRunPropagatesInitializeException(): void - { - $this->transport->expects($this->once()) - ->method('initialize') - ->willThrowException(new \RuntimeException('Initialize error')); - - $server = new Server($this->protocol); - - $this->expectException(\RuntimeException::class); - $this->expectExceptionMessage('Initialize error'); - - $server->run($this->transport); - } - - #[TestDox('run() returns value from transport.listen()')] - public function testRunReturnsTransportListenValue(): void - { - $this->transport->method('initialize'); - $this->protocol->method('connect'); - $this->transport->method('close'); - - $expectedReturn = 42; - $this->transport->expects($this->once()) - ->method('listen') - ->willReturn($expectedReturn); - - $server = new Server($this->protocol); - $result = $server->run($this->transport); - - $this->assertEquals($expectedReturn, $result); - } - - #[TestDox('run() connects protocol to transport')] - public function testRunConnectsProtocolToTransport(): void - { - $this->transport->method('initialize'); - $this->transport->method('listen')->willReturn(0); - $this->transport->method('close'); - - $this->protocol->expects($this->once()) - ->method('connect') - ->with($this->identicalTo($this->transport)); - - $server = new Server($this->protocol); - $server->run($this->transport); - } -}